Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/spicy-hover-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/backend': patch
---

Improve editor hover types for `authenticateRequest()`, `auth()`, and `getAuth()` when using `acceptsToken`. Machine auth results now display as named discriminated unions (e.g. `AuthenticatedMachineObjectFor<"api_key"> | UnauthenticatedMachineObjectFor<"api_key">`) instead of expanded intersection types.

The internal `InferAuthObjectFromToken` and `InferAuthObjectFromTokenArray` types are deprecated in favor of the new `InferAuthObject` type and will be removed in the next major version.

Narrowing a `RequestState` by `tokenType` now also narrows the return type of `toAuth()`, and debug data is no longer dropped when an auth object is downgraded because its token type did not match `acceptsToken`.
1 change: 1 addition & 0 deletions packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { debugRequestState } from './tokens/request';
export type {
AuthenticateRequestOptions,
OrganizationSyncOptions,
InferAuthObject,
InferAuthObjectFromToken,
InferAuthObjectFromTokenArray,
GetAuthFn,
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,14 @@ describe('getAuthObjectForAcceptedToken', () => {
expect((result as UnauthenticatedMachineObject<'m2m_token'>).tokenType).toBe('m2m_token');
expect((result as UnauthenticatedMachineObject<'m2m_token'>).id).toBeNull();
});

it('carries debug data over to the downgraded auth object', () => {
const machineResult = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'm2m_token' });
expect(machineResult.debug()).toMatchObject({ foo: 'bar' });

const sessionResult = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'session_token' });
expect(sessionResult.debug()).toMatchObject({ foo: 'bar' });
});
});

describe('getToken with expiresInSeconds support', () => {
Expand Down
172 changes: 170 additions & 2 deletions packages/backend/src/tokens/__tests__/getAuth.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import type { PendingSessionOptions } from '@clerk/shared/types';
import { describe, expectTypeOf, test } from 'vitest';

import type { RedirectFun } from '../../createRedirect';
import type { AuthObject, InvalidTokenAuthObject } from '../authObjects';
import type { GetAuthFn, GetAuthFnNoRequest, MachineAuthObject, SessionAuthObject } from '../types';
import type {
AuthenticatedMachineObject,
AuthObject,
InvalidTokenAuthObject,
SignedInAuthObject,
} from '../authObjects';
import type { TokenType } from '../tokenTypes';
import type {
GetAuthFn,
GetAuthFnNoRequest,
InferAuthObjectFromToken,
InferAuthObjectFromTokenArray,
MachineAuthObject,
SessionAuthObject,
} from '../types';

describe('getAuth() or auth() with request parameter', () => {
const getAuth: GetAuthFn<Request> = (_request: any, _options: any) => {
Expand Down Expand Up @@ -97,3 +111,157 @@ describe('getAuth() or auth() without request parameter', () => {
}
});
});

describe('contract pins: mutual assignability of every return type', () => {
const getAuth: GetAuthFn<Request> = (_request: any, _options: any) => {
return {} as any;
};
const request = new Request('https://example.com');

test('single token types resolve to exactly the clean unions', () => {
const def = getAuth(request);
expectTypeOf(def).toEqualTypeOf<SessionAuthObject>();

const session = getAuth(request, { acceptsToken: 'session_token' });
expectTypeOf(session).toEqualTypeOf<SessionAuthObject>();

const apiKey = getAuth(request, { acceptsToken: 'api_key' });
expectTypeOf(apiKey).toExtend<MachineAuthObject<'api_key'>>();
expectTypeOf<MachineAuthObject<'api_key'>>().toExtend<typeof apiKey>();

const m2m = getAuth(request, { acceptsToken: 'm2m_token' });
expectTypeOf(m2m).toExtend<MachineAuthObject<'m2m_token'>>();
expectTypeOf<MachineAuthObject<'m2m_token'>>().toExtend<typeof m2m>();

const oauth = getAuth(request, { acceptsToken: 'oauth_token' });
expectTypeOf(oauth).toExtend<MachineAuthObject<'oauth_token'>>();
expectTypeOf<MachineAuthObject<'oauth_token'>>().toExtend<typeof oauth>();
});

test('array token types include InvalidTokenAuthObject and the clean per-token unions', () => {
const sessionOnly = getAuth(request, { acceptsToken: ['session_token'] });
expectTypeOf(sessionOnly).toExtend<SessionAuthObject | InvalidTokenAuthObject>();
expectTypeOf<SessionAuthObject | InvalidTokenAuthObject>().toExtend<typeof sessionOnly>();

const mixed = getAuth(request, { acceptsToken: ['session_token', 'm2m_token'] });
expectTypeOf(mixed).toExtend<SessionAuthObject | MachineAuthObject<'m2m_token'> | InvalidTokenAuthObject>();
expectTypeOf<SessionAuthObject | MachineAuthObject<'m2m_token'> | InvalidTokenAuthObject>().toExtend<
typeof mixed
>();

const machineOnly = getAuth(request, { acceptsToken: ['m2m_token', 'oauth_token'] });
expectTypeOf(machineOnly).toExtend<MachineAuthObject<'m2m_token' | 'oauth_token'> | InvalidTokenAuthObject>();
expectTypeOf<MachineAuthObject<'m2m_token' | 'oauth_token'> | InvalidTokenAuthObject>().toExtend<
typeof machineOnly
>();
});

test('widened TokenType[] arrays resolve to the full union', () => {
const widenedTokens: TokenType[] = ['session_token', 'api_key'];
const widened = getAuth(request, { acceptsToken: widenedTokens });
expectTypeOf(widened).toExtend<
SessionAuthObject | MachineAuthObject<'api_key' | 'm2m_token' | 'oauth_token'> | InvalidTokenAuthObject
>();
expectTypeOf<
SessionAuthObject | MachineAuthObject<'api_key' | 'm2m_token' | 'oauth_token'> | InvalidTokenAuthObject
>().toExtend<typeof widened>();
});

test('acceptsToken: any resolves to exactly AuthObject', () => {
const any = getAuth(request, { acceptsToken: 'any' });
expectTypeOf(any).toEqualTypeOf<AuthObject>();
});

test('narrowing on tokenType is exhaustive for array token types', () => {
const auth = getAuth(request, { acceptsToken: ['session_token', 'api_key'] });
switch (auth.tokenType) {
case 'session_token':
expectTypeOf(auth).toExtend<SessionAuthObject>();
break;
case 'api_key':
expectTypeOf(auth).toExtend<MachineAuthObject<'api_key'>>();
break;
case null:
expectTypeOf(auth).toEqualTypeOf<InvalidTokenAuthObject>();
break;
default:
expectTypeOf(auth).toBeNever();
}
});

test('pins Parameters/ReturnType extraction to the last overload', () => {
expectTypeOf<Parameters<typeof getAuth>>().toEqualTypeOf<[req: Request, options?: PendingSessionOptions]>();
expectTypeOf<ReturnType<typeof getAuth>>().toEqualTypeOf<SessionAuthObject>();
});
});

describe('contract pins: InferAuthObjectFromToken(Array) support protect()-style usage', () => {
// Mimic @clerk/nextjs protect(), which passes a bare AuthenticatedMachineObject union as MachineType.
test('single token helper accepts every clean machine member', () => {
type Session = InferAuthObjectFromToken<'session_token', SignedInAuthObject, AuthenticatedMachineObject>;
expectTypeOf<Session>().toEqualTypeOf<SignedInAuthObject>();

type ApiKey = InferAuthObjectFromToken<'api_key', SignedInAuthObject, AuthenticatedMachineObject>;
expectTypeOf<AuthenticatedMachineObject<'api_key'>>().toExtend<ApiKey>();
});

test('array helper accepts every clean member per token type', () => {
type Mixed = InferAuthObjectFromTokenArray<
('session_token' | 'm2m_token')[],
SignedInAuthObject,
AuthenticatedMachineObject
>;
expectTypeOf<SignedInAuthObject | AuthenticatedMachineObject<'m2m_token'>>().toExtend<Mixed>();

type MachineOnly = InferAuthObjectFromTokenArray<
('m2m_token' | 'oauth_token')[],
SignedInAuthObject,
AuthenticatedMachineObject
>;
expectTypeOf<AuthenticatedMachineObject<'m2m_token' | 'oauth_token'>>().toExtend<MachineOnly>();
Comment on lines +204 to +221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the inverse assignability for direct helper contracts.

These assertions do not fail if either helper returns all machine auth members. Add the inverse assertion, or use toEqualTypeOf, for ApiKey, Mixed, and MachineOnly. This keeps the direct @clerk/backend/internal helper contract token-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/tokens/__tests__/getAuth.test-d.ts` around lines 206 -
223, Strengthen the type assertions in the direct helper contract tests for
ApiKey, Mixed, and MachineOnly so they verify exact assignability in both
directions. Replace the one-way toExtend checks with toEqualTypeOf where
appropriate, or add the inverse assertions, ensuring each helper remains
token-specific rather than accepting broader machine auth members.

});
});

describe('contract pins: GetAuthFnNoRequest mutual assignability', () => {
type SessionAuthWithRedirect = SessionAuthObject & {
redirectToSignIn: RedirectFun<Response>;
redirectToSignUp: RedirectFun<Response>;
};

const auth: GetAuthFnNoRequest<SessionAuthWithRedirect, true> = (_options: any) => {
return {} as any;
};

test('machine and mixed return types accept every clean member', async () => {
const apiKey = await auth({ acceptsToken: 'api_key' });
expectTypeOf<MachineAuthObject<'api_key'>>().toExtend<typeof apiKey>();

const mixed = await auth({ acceptsToken: ['session_token', 'm2m_token'] });
expectTypeOf<SessionAuthWithRedirect | MachineAuthObject<'m2m_token'> | InvalidTokenAuthObject>().toExtend<
typeof mixed
>();

const any = await auth({ acceptsToken: 'any' });
expectTypeOf<Exclude<AuthObject, SessionAuthObject> | SessionAuthWithRedirect>().toExtend<typeof any>();
expectTypeOf(any).toExtend<Exclude<AuthObject, SessionAuthObject> | SessionAuthWithRedirect>();
});

test('pins Parameters/ReturnType extraction to the last overload', () => {
expectTypeOf<Parameters<typeof auth>>().toEqualTypeOf<[options?: PendingSessionOptions]>();
expectTypeOf<ReturnType<typeof auth>>().toEqualTypeOf<Promise<SessionAuthWithRedirect>>();
});
});

describe('contract pins: any-typed acceptsToken collapses to any', () => {
// nextjs auth.protect() passes an untyped token and dereferences the result,
// so `any` inputs must keep resolving to `any`.
const getAuth: GetAuthFn<Request> = (_request: any, _options: any) => {
return {} as any;
};

test('any input keeps resolving to any', () => {
const anyToken = undefined as any;
const auth = getAuth(new Request('https://example.com'), { acceptsToken: anyToken });
expectTypeOf(auth).toBeAny();
});
});
88 changes: 77 additions & 11 deletions packages/backend/src/tokens/__tests__/request.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,99 @@
import { expectTypeOf, test } from 'vitest';

import type { RequestState, TokenType } from '../../internal';
import { authenticateRequest } from '../../tokens/request';
import type { AuthenticateRequestOptions, RequestState, TokenType } from '../../internal';
import type { AuthenticatedMachineObject, InvalidTokenAuthObject, SignedInAuthObject } from '../authObjects';
import type { AuthenticatedState, HandshakeState, UnauthenticatedState } from '../authStatus';
import { authenticateRequest } from '../request';

test('returns the correct `authenticateRequest()` return type for each accepted token type', () => {
const request = new Request('https://example.com');

// Session token by default
expectTypeOf(authenticateRequest(request)).toMatchTypeOf<Promise<RequestState>>();
expectTypeOf(authenticateRequest(request)).toExtend<Promise<RequestState>>();

// Individual token types
expectTypeOf(authenticateRequest(request, { acceptsToken: 'session_token' })).toMatchTypeOf<
expectTypeOf(authenticateRequest(request, { acceptsToken: 'session_token' })).toExtend<
Promise<RequestState<'session_token'>>
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).toMatchTypeOf<
Promise<RequestState<'api_key'>>
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).toMatchTypeOf<
expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).toExtend<Promise<RequestState<'api_key'>>>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).toExtend<
Promise<RequestState<'m2m_token'>>
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'oauth_token' })).toMatchTypeOf<
expectTypeOf(authenticateRequest(request, { acceptsToken: 'oauth_token' })).toExtend<
Promise<RequestState<'oauth_token'>>
>();

// Array of token types
expectTypeOf(authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'm2m_token'] })).toMatchTypeOf<
expectTypeOf(authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'm2m_token'] })).toExtend<
Promise<RequestState<'session_token' | 'api_key' | 'm2m_token' | null>>
>();

// Any token type
expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toMatchTypeOf<Promise<RequestState<TokenType>>>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toExtend<Promise<RequestState<TokenType>>>();
});

test('pins the exact resolved state union per accepted token type', () => {
const request = new Request('https://example.com');

// Session tokens (and the no-options default) include HandshakeState
expectTypeOf(authenticateRequest(request)).resolves.toEqualTypeOf<
AuthenticatedState<'session_token'> | UnauthenticatedState<'session_token'> | HandshakeState
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'session_token' })).resolves.toEqualTypeOf<
AuthenticatedState<'session_token'> | UnauthenticatedState<'session_token'> | HandshakeState
>();

// Machine tokens never produce a HandshakeState or a null tokenType
expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).resolves.toEqualTypeOf<
AuthenticatedState<'api_key'> | UnauthenticatedState<'api_key'>
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).resolves.toEqualTypeOf<
AuthenticatedState<'m2m_token'> | UnauthenticatedState<'m2m_token'>
>();

// Arrays add `null` to the unauthenticated side (invalid token) and keep
// HandshakeState only when session_token is a member
expectTypeOf(authenticateRequest(request, { acceptsToken: ['session_token', 'api_key'] })).resolves.toEqualTypeOf<
| AuthenticatedState<'session_token' | 'api_key'>
| UnauthenticatedState<'session_token' | 'api_key' | null>
| HandshakeState
>();
expectTypeOf(authenticateRequest(request, { acceptsToken: ['m2m_token', 'oauth_token'] })).resolves.toEqualTypeOf<
AuthenticatedState<'m2m_token' | 'oauth_token'> | UnauthenticatedState<'m2m_token' | 'oauth_token' | null>
>();
});

test('accepts widened token type arrays but rejects readonly arrays', () => {
const request = new Request('https://example.com');

const readonlyTokens = ['session_token', 'api_key'] as const;
// @ts-expect-error acceptsToken is typed as a mutable TokenType[], so `as const` arrays are rejected
void authenticateRequest(request, { acceptsToken: readonlyTokens });

const widenedTokens: TokenType[] = ['session_token', 'api_key'];
expectTypeOf(authenticateRequest(request, { acceptsToken: widenedTokens })).toExtend<
Promise<RequestState<TokenType | null>>
>();
});

test('pins Parameters/ReturnType extraction to the last overload', () => {
expectTypeOf<Parameters<typeof authenticateRequest>>().toEqualTypeOf<
[request: Request, options?: AuthenticateRequestOptions]
>();
expectTypeOf<ReturnType<typeof authenticateRequest>>().toEqualTypeOf<Promise<RequestState<'session_token'>>>();
});

test('narrowing tokenType on a state narrows the toAuth return type', async () => {
const request = new Request('https://example.com');
const state = await authenticateRequest(request, { acceptsToken: ['session_token', 'api_key'] });

if (state.status === 'signed-in' && state.tokenType === 'session_token') {
expectTypeOf(state.toAuth({ treatPendingAsSignedOut: true })).toEqualTypeOf<SignedInAuthObject>();
}
if (state.status === 'signed-in' && state.tokenType === 'api_key') {
expectTypeOf(state.toAuth()).toEqualTypeOf<AuthenticatedMachineObject<'api_key'>>();
}
if (state.status === 'signed-out' && state.tokenType === null) {
expectTypeOf(state.toAuth()).toEqualTypeOf<InvalidTokenAuthObject>();
}
});
Loading
Loading