diff --git a/.changeset/spicy-hover-types.md b/.changeset/spicy-hover-types.md new file mode 100644 index 00000000000..ff84c3f745c --- /dev/null +++ b/.changeset/spicy-hover-types.md @@ -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`. diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 27b44f31f98..632cc687599 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -10,6 +10,7 @@ export { debugRequestState } from './tokens/request'; export type { AuthenticateRequestOptions, OrganizationSyncOptions, + InferAuthObject, InferAuthObjectFromToken, InferAuthObjectFromTokenArray, GetAuthFn, diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index fde4ec53c75..2227a43bc88 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -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', () => { diff --git a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts index 79f03809904..696195ab077 100644 --- a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts +++ b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts @@ -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: any, _options: any) => { @@ -97,3 +111,157 @@ describe('getAuth() or auth() without request parameter', () => { } }); }); + +describe('contract pins: mutual assignability of every return type', () => { + const getAuth: GetAuthFn = (_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(); + + const session = getAuth(request, { acceptsToken: 'session_token' }); + expectTypeOf(session).toEqualTypeOf(); + + const apiKey = getAuth(request, { acceptsToken: 'api_key' }); + expectTypeOf(apiKey).toExtend>(); + expectTypeOf>().toExtend(); + + const m2m = getAuth(request, { acceptsToken: 'm2m_token' }); + expectTypeOf(m2m).toExtend>(); + expectTypeOf>().toExtend(); + + const oauth = getAuth(request, { acceptsToken: 'oauth_token' }); + expectTypeOf(oauth).toExtend>(); + expectTypeOf>().toExtend(); + }); + + test('array token types include InvalidTokenAuthObject and the clean per-token unions', () => { + const sessionOnly = getAuth(request, { acceptsToken: ['session_token'] }); + expectTypeOf(sessionOnly).toExtend(); + expectTypeOf().toExtend(); + + const mixed = getAuth(request, { acceptsToken: ['session_token', 'm2m_token'] }); + expectTypeOf(mixed).toExtend | InvalidTokenAuthObject>(); + expectTypeOf | InvalidTokenAuthObject>().toExtend< + typeof mixed + >(); + + const machineOnly = getAuth(request, { acceptsToken: ['m2m_token', 'oauth_token'] }); + expectTypeOf(machineOnly).toExtend | InvalidTokenAuthObject>(); + expectTypeOf | 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(); + }); + + test('acceptsToken: any resolves to exactly AuthObject', () => { + const any = getAuth(request, { acceptsToken: 'any' }); + expectTypeOf(any).toEqualTypeOf(); + }); + + 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(); + break; + case 'api_key': + expectTypeOf(auth).toExtend>(); + break; + case null: + expectTypeOf(auth).toEqualTypeOf(); + break; + default: + expectTypeOf(auth).toBeNever(); + } + }); + + test('pins Parameters/ReturnType extraction to the last overload', () => { + expectTypeOf>().toEqualTypeOf<[req: Request, options?: PendingSessionOptions]>(); + expectTypeOf>().toEqualTypeOf(); + }); +}); + +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().toEqualTypeOf(); + + type ApiKey = InferAuthObjectFromToken<'api_key', SignedInAuthObject, AuthenticatedMachineObject>; + expectTypeOf>().toExtend(); + }); + + test('array helper accepts every clean member per token type', () => { + type Mixed = InferAuthObjectFromTokenArray< + ('session_token' | 'm2m_token')[], + SignedInAuthObject, + AuthenticatedMachineObject + >; + expectTypeOf>().toExtend(); + + type MachineOnly = InferAuthObjectFromTokenArray< + ('m2m_token' | 'oauth_token')[], + SignedInAuthObject, + AuthenticatedMachineObject + >; + expectTypeOf>().toExtend(); + }); +}); + +describe('contract pins: GetAuthFnNoRequest mutual assignability', () => { + type SessionAuthWithRedirect = SessionAuthObject & { + redirectToSignIn: RedirectFun; + redirectToSignUp: RedirectFun; + }; + + const auth: GetAuthFnNoRequest = (_options: any) => { + return {} as any; + }; + + test('machine and mixed return types accept every clean member', async () => { + const apiKey = await auth({ acceptsToken: 'api_key' }); + expectTypeOf>().toExtend(); + + const mixed = await auth({ acceptsToken: ['session_token', 'm2m_token'] }); + expectTypeOf | InvalidTokenAuthObject>().toExtend< + typeof mixed + >(); + + const any = await auth({ acceptsToken: 'any' }); + expectTypeOf | SessionAuthWithRedirect>().toExtend(); + expectTypeOf(any).toExtend | SessionAuthWithRedirect>(); + }); + + test('pins Parameters/ReturnType extraction to the last overload', () => { + expectTypeOf>().toEqualTypeOf<[options?: PendingSessionOptions]>(); + expectTypeOf>().toEqualTypeOf>(); + }); +}); + +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: 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(); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/request.test-d.ts b/packages/backend/src/tokens/__tests__/request.test-d.ts index ec80f32b421..e4a22d19844 100644 --- a/packages/backend/src/tokens/__tests__/request.test-d.ts +++ b/packages/backend/src/tokens/__tests__/request.test-d.ts @@ -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>(); + expectTypeOf(authenticateRequest(request)).toExtend>(); // Individual token types - expectTypeOf(authenticateRequest(request, { acceptsToken: 'session_token' })).toMatchTypeOf< + expectTypeOf(authenticateRequest(request, { acceptsToken: 'session_token' })).toExtend< Promise> >(); - expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).toMatchTypeOf< - Promise> - >(); - expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).toMatchTypeOf< + expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).toExtend>>(); + expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).toExtend< Promise> >(); - expectTypeOf(authenticateRequest(request, { acceptsToken: 'oauth_token' })).toMatchTypeOf< + expectTypeOf(authenticateRequest(request, { acceptsToken: 'oauth_token' })).toExtend< Promise> >(); // 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> >(); // Any token type - expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toMatchTypeOf>>(); + expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toExtend>>(); +}); + +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> + >(); +}); + +test('pins Parameters/ReturnType extraction to the last overload', () => { + expectTypeOf>().toEqualTypeOf< + [request: Request, options?: AuthenticateRequestOptions] + >(); + expectTypeOf>().toEqualTypeOf>>(); +}); + +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(); + } + if (state.status === 'signed-in' && state.tokenType === 'api_key') { + expectTypeOf(state.toAuth()).toEqualTypeOf>(); + } + if (state.status === 'signed-out' && state.tokenType === null) { + expectTypeOf(state.toAuth()).toEqualTypeOf(); + } }); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..22421de51b5 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -113,17 +113,19 @@ type MachineObjectExtendedProperties = { * individually, creating proper discriminated unions where each token type * gets its own distinct properties (e.g., oauth_token won't have claims). */ +type AuthenticatedMachineObjectFor = { + id: string; + subject: string; + scopes: string[]; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; + isAuthenticated: true; +} & MachineObjectExtendedProperties[T]; + export type AuthenticatedMachineObject = T extends any - ? { - id: string; - subject: string; - scopes: string[]; - getToken: () => Promise; - has: CheckAuthorizationFromSessionClaims; - debug: AuthObjectDebug; - tokenType: T; - isAuthenticated: true; - } & MachineObjectExtendedProperties[T] + ? AuthenticatedMachineObjectFor : never; /** @@ -134,17 +136,19 @@ export type AuthenticatedMachineObject = { + id: null; + subject: null; + scopes: null; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; + isAuthenticated: false; +} & MachineObjectExtendedProperties[T]; + export type UnauthenticatedMachineObject = T extends any - ? { - id: null; - subject: null; - scopes: null; - getToken: () => Promise; - has: CheckAuthorizationFromSessionClaims; - debug: AuthObjectDebug; - tokenType: T; - isAuthenticated: false; - } & MachineObjectExtendedProperties[T] + ? UnauthenticatedMachineObjectFor : never; export type InvalidTokenAuthObject = { @@ -272,45 +276,50 @@ export function authenticatedMachineObject( getToken: () => Promise.resolve(token), has: () => false, debug: createDebug(debugData), - isAuthenticated: true, + isAuthenticated: true as const, }; - // Type assertions are safe here since we know the verification result type matches the tokenType. - // We need these assertions because TS can't infer the specific type - // just from the tokenType discriminator. - + // Each branch literal is checked against its concrete object type; the final cast only bridges + // the generic T, which TS can't correlate with the narrowed branch. switch (tokenType) { case TokenType.ApiKey: { const result = verificationResult as APIKey; - return { + // FAPI guarantees an api_key subject is a user_ or org_ id, so exactly one of the checks matches. + const ownership = { + userId: result.subject.startsWith('user_') ? result.subject : null, + orgId: result.subject.startsWith('org_') ? result.subject : null, + } as { userId: string; orgId: null } | { userId: null; orgId: string }; + const authObject: AuthenticatedMachineObjectFor<'api_key'> = { ...baseObject, tokenType, name: result.name, claims: result.claims, scopes: result.scopes, - userId: result.subject.startsWith('user_') ? result.subject : null, - orgId: result.subject.startsWith('org_') ? result.subject : null, - } as unknown as AuthenticatedMachineObject; + ...ownership, + }; + return authObject as unknown as AuthenticatedMachineObject; } case TokenType.M2MToken: { const result = verificationResult as M2MToken; - return { + const authObject: AuthenticatedMachineObjectFor<'m2m_token'> = { ...baseObject, tokenType, claims: result.claims, scopes: result.scopes, machineId: result.subject, - } as unknown as AuthenticatedMachineObject; + }; + return authObject as unknown as AuthenticatedMachineObject; } case TokenType.OAuthToken: { const result = verificationResult as IdPOAuthAccessToken; - return { + const authObject: AuthenticatedMachineObjectFor<'oauth_token'> = { ...baseObject, tokenType, scopes: result.scopes, userId: result.subject, clientId: result.clientId, - } as unknown as AuthenticatedMachineObject; + }; + return authObject as unknown as AuthenticatedMachineObject; } default: throw new Error(`Invalid token type: ${tokenType}`); @@ -331,12 +340,12 @@ export function unauthenticatedMachineObject( has: () => false, getToken: () => Promise.resolve(null), debug: createDebug(debugData), - isAuthenticated: false, + isAuthenticated: false as const, }; switch (tokenType) { case TokenType.ApiKey: { - return { + const authObject: UnauthenticatedMachineObjectFor<'api_key'> = { ...baseObject, tokenType, name: null, @@ -344,25 +353,28 @@ export function unauthenticatedMachineObject( scopes: null, userId: null, orgId: null, - } as unknown as UnauthenticatedMachineObject; + }; + return authObject as unknown as UnauthenticatedMachineObject; } case TokenType.M2MToken: { - return { + const authObject: UnauthenticatedMachineObjectFor<'m2m_token'> = { ...baseObject, tokenType, claims: null, scopes: null, machineId: null, - } as unknown as UnauthenticatedMachineObject; + }; + return authObject as unknown as UnauthenticatedMachineObject; } case TokenType.OAuthToken: { - return { + const authObject: UnauthenticatedMachineObjectFor<'oauth_token'> = { ...baseObject, tokenType, scopes: null, userId: null, clientId: null, - } as unknown as UnauthenticatedMachineObject; + }; + return authObject as unknown as UnauthenticatedMachineObject; } default: throw new Error(`Invalid token type: ${tokenType}`); @@ -494,9 +506,9 @@ export const getAuthObjectForAcceptedToken = ({ // 3. single token: must match exactly, else return appropriate unauthenticated object if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { if (isMachineTokenType(acceptsToken)) { - return unauthenticatedMachineObject(acceptsToken, authObject.debug); + return unauthenticatedMachineObject(acceptsToken, authObject.debug?.()); } - return signedOutAuthObject(authObject.debug); + return signedOutAuthObject(authObject.debug?.()); } // 4. default: return as-is diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 421c7bd61f4..05744a23473 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -39,7 +39,7 @@ type ToAuth = T exten ? () => AuthenticatedMachineObject> : () => UnauthenticatedMachineObject>; -export type AuthenticatedState = { +type AuthenticatedStateFor = { status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -62,7 +62,11 @@ export type AuthenticatedState = { toAuth: ToAuth; }; -export type UnauthenticatedState = { +export type AuthenticatedState = T extends any + ? AuthenticatedStateFor + : never; + +type UnauthenticatedStateFor = { status: typeof AuthStatus.SignedOut; reason: AuthReason; message: string; @@ -85,6 +89,10 @@ export type UnauthenticatedState toAuth: ToAuth; }; +export type UnauthenticatedState = T extends any + ? UnauthenticatedStateFor + : never; + export type HandshakeState = Omit, 'status' | 'toAuth' | 'tokenType'> & { tokenType: SessionTokenType; status: typeof AuthStatus.Handshake; @@ -160,7 +168,7 @@ export function signedIn(params: SignedInParams & { tokenTy return authenticatedMachineObject(params.tokenType, token, machineData, authenticateContext); }) as ToAuth; - return { + const state: AuthenticatedStateFor = { status: AuthStatus.SignedIn, reason: null, message: null, @@ -179,6 +187,7 @@ export function signedIn(params: SignedInParams & { tokenTy headers, token, }; + return state as AuthenticatedState; } type SignedOutParams = Omit & { @@ -197,7 +206,7 @@ export function signedOut(params: SignedOutParams & { token return unauthenticatedMachineObject(tokenType, { reason, message, headers }); }) as ToAuth; - return withDebugHeaders({ + const state: UnauthenticatedStateFor = { status: AuthStatus.SignedOut, reason, message, @@ -215,7 +224,8 @@ export function signedOut(params: SignedOutParams & { token toAuth, headers, token: null, - }); + }; + return withDebugHeaders(state) as UnauthenticatedState; } export function handshake( diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 823503a4aba..f764da498dd 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -170,31 +170,40 @@ export type OrganizationSyncTarget = | { type: 'personalAccount' } | { type: 'organization'; organizationId?: string; organizationSlug?: string }; +/** + * Maps an accepted token type to its auth object. Distributes over `T`, so a union + * of token types resolves to the union of their auth objects: session tokens + * resolve to `SessionType`, and each machine token selects the members of + * `MachineType` carrying its `tokenType` discriminant. + */ +export type InferAuthObject< + T extends TokenType, + SessionType extends AuthObject, + MachineType extends AuthObject, +> = T extends SessionTokenType ? SessionType : Extract; + +// Both deprecated delegates are unused in-repo but imported by published 3.x SDK dists, so they must exist until the next major. /** * Infers auth object type from an array of token types. - * - Session token only -> SessionType - * - Mixed tokens -> SessionType | MachineType - * - Machine tokens only -> MachineType + * + * @deprecated Use `InferAuthObject` with `T[number]` instead. Will be removed in the next major version. */ export type InferAuthObjectFromTokenArray< T extends readonly TokenType[], SessionType extends AuthObject, MachineType extends AuthObject, -> = SessionTokenType extends T[number] - ? T[number] extends SessionTokenType - ? SessionType - : SessionType | (MachineType & { tokenType: Exclude }) - : MachineType & { tokenType: Exclude }; +> = InferAuthObject; /** * Infers auth object type from a single token type. - * Returns SessionType for session tokens, or MachineType for machine tokens. + * + * @deprecated Use `InferAuthObject` instead. Will be removed in the next major version. */ export type InferAuthObjectFromToken< T extends TokenType, SessionType extends AuthObject, MachineType extends AuthObject, -> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: Exclude }; +> = InferAuthObject; export type SessionAuthObject = SignedInAuthObject | SignedOutAuthObject; export type MachineAuthObject> = T extends any @@ -219,7 +228,7 @@ export interface GetAuthFn req: RequestType, options: AuthOptions & { acceptsToken: T }, ): MaybePromise< - | InferAuthObjectFromTokenArray>> + | InferAuthObject>> | InvalidTokenAuthObject, ReturnsPromise >; @@ -232,7 +241,7 @@ export interface GetAuthFn req: RequestType, options: AuthOptions & { acceptsToken: T }, ): MaybePromise< - InferAuthObjectFromToken>>, + InferAuthObject>>, ReturnsPromise >; @@ -266,7 +275,7 @@ export interface GetAuthFnNoRequest< ( options: AuthOptions & { acceptsToken: T }, ): MaybePromise< - | InferAuthObjectFromTokenArray>> + | InferAuthObject>> | InvalidTokenAuthObject, ReturnsPromise >; @@ -277,10 +286,7 @@ export interface GetAuthFnNoRequest< */ ( options: AuthOptions & { acceptsToken: T }, - ): MaybePromise< - InferAuthObjectFromToken>>, - ReturnsPromise - >; + ): MaybePromise>>, ReturnsPromise>; /** * @example diff --git a/packages/backend/vitest.config.mts b/packages/backend/vitest.config.mts index c690ee913f2..f69126c7880 100644 --- a/packages/backend/vitest.config.mts +++ b/packages/backend/vitest.config.mts @@ -5,7 +5,7 @@ export default defineConfig({ test: { typecheck: { enabled: true, - include: ['**/*.test.ts'], + include: ['**/*.test.ts', '**/*.test-d.ts'], }, coverage: { provider: 'v8', diff --git a/packages/nextjs/src/server/__tests__/protect.test-d.ts b/packages/nextjs/src/server/__tests__/protect.test-d.ts new file mode 100644 index 00000000000..b4950816368 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/protect.test-d.ts @@ -0,0 +1,46 @@ +import type { AuthenticatedMachineObject, SignedInAuthObject } from '@clerk/backend/internal'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { AuthProtect } from '../protect'; + +describe('auth.protect() return types', () => { + const protect = {} as AuthProtect; + + test('resolves to SignedInAuthObject for session usage', () => { + expectTypeOf(protect()).resolves.toEqualTypeOf(); + expectTypeOf(protect({ permission: 'org:admin:example' })).resolves.toEqualTypeOf(); + expectTypeOf(protect(has => has({ permission: 'org:admin:example' }))).resolves.toEqualTypeOf(); + expectTypeOf(protect({ token: 'session_token' })).resolves.toEqualTypeOf(); + }); + + test('resolves to the matching authenticated machine object for machine tokens', () => { + expectTypeOf(protect({ token: 'api_key' })).resolves.toEqualTypeOf>(); + expectTypeOf(protect({ token: 'm2m_token' })).resolves.toEqualTypeOf>(); + expectTypeOf(protect({ token: 'oauth_token' })).resolves.toEqualTypeOf>(); + }); + + test('resolves to the union of accepted token types for arrays', () => { + const mixed = protect({ token: ['session_token', 'm2m_token'] }); + expectTypeOf(mixed).resolves.toExtend>(); + expectTypeOf>>().toExtend(); + + const machineOnly = protect({ token: ['api_key', 'oauth_token'] }); + expectTypeOf(machineOnly).resolves.toExtend>(); + expectTypeOf>>().toExtend(); + }); + + test('resolves to the full union for token: any', () => { + expectTypeOf(protect({ token: 'any' })).resolves.toEqualTypeOf(); + }); + + test('narrows machine results by tokenType', async () => { + const auth = await protect({ token: ['session_token', 'api_key'] }); + if (auth.tokenType === 'api_key') { + expectTypeOf(auth).toExtend>(); + expectTypeOf(auth.name).toBeString(); + } + if (auth.tokenType === 'session_token') { + expectTypeOf(auth).toExtend(); + } + }); +}); diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 1e72f7128fd..3df8bbb7dfc 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -2,8 +2,7 @@ import type { AuthObject } from '@clerk/backend'; import type { AuthenticatedMachineObject, AuthenticateRequestOptions, - InferAuthObjectFromToken, - InferAuthObjectFromTokenArray, + InferAuthObject, RedirectFun, SignedInAuthObject, } from '@clerk/backend/internal'; @@ -63,7 +62,7 @@ export interface AuthProtect { */ ( options?: AuthProtectOptions & { token: T }, - ): Promise>; + ): Promise>; /** * @example @@ -71,7 +70,7 @@ export interface AuthProtect { */ ( options?: AuthProtectOptions & { token: T }, - ): Promise>; + ): Promise>; /** * @example diff --git a/packages/nextjs/vitest.config.mts b/packages/nextjs/vitest.config.mts index ffe3c21de12..5d718b92658 100644 --- a/packages/nextjs/vitest.config.mts +++ b/packages/nextjs/vitest.config.mts @@ -6,7 +6,7 @@ export default defineConfig({ typecheck: { enabled: true, tsconfig: './tsconfig.test.json', - include: ['**/*.{test.ts,test.tsx}'], + include: ['**/*.{test.ts,test.tsx}', '**/*.test-d.ts'], }, env: { CLERK_SECRET_KEY: 'TEST_SECRET_KEY',