diff --git a/.changeset/social-carrots-melt.md b/.changeset/social-carrots-melt.md new file mode 100644 index 00000000000..b3effe5a915 --- /dev/null +++ b/.changeset/social-carrots-melt.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': patch +'@clerk/nextjs': patch +--- + +Resolve machine token property mixing in discriminated unions \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index eadfe571e51..2b33d227388 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -162,3 +162,4 @@ export type { * Auth objects */ export type { AuthObject } from './tokens/authObjects'; +export type { SessionAuthObject, MachineAuthObject } from './tokens/types'; diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index fc729046e19..4f176cc0489 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -12,8 +12,6 @@ export type { OrganizationSyncOptions, InferAuthObjectFromToken, InferAuthObjectFromTokenArray, - SessionAuthObject, - MachineAuthObject, GetAuthFn, } from './tokens/types'; diff --git a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts new file mode 100644 index 00000000000..ec38dc42ffc --- /dev/null +++ b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts @@ -0,0 +1,107 @@ +import { assertType, test } from 'vitest'; + +import type { AuthObject } from '../authObjects'; +import type { GetAuthFn, MachineAuthObject, SessionAuthObject } from '../types'; + +// Across our SDKs, we have a getAuth() function +const getAuth: GetAuthFn = (_request: any, _options: any) => { + return {} as any; +}; + +test('infers the correct AuthObject type for each accepted token type', () => { + const request = new Request('https://example.com'); + + // Session token by default + assertType(getAuth(request)); + + // Individual token types + assertType(getAuth(request, { acceptsToken: 'session_token' })); + assertType>(getAuth(request, { acceptsToken: 'api_key' })); + assertType>(getAuth(request, { acceptsToken: 'machine_token' })); + assertType>(getAuth(request, { acceptsToken: 'oauth_token' })); + + // Array of token types + assertType>( + getAuth(request, { acceptsToken: ['session_token', 'machine_token'] }), + ); + assertType>( + getAuth(request, { acceptsToken: ['machine_token', 'oauth_token'] }), + ); + + // Any token type + assertType(getAuth(request, { acceptsToken: 'any' })); +}); + +test('verifies correct properties exist for each token type', () => { + const request = new Request('https://example.com'); + + // Session token should have userId + const sessionAuth = getAuth(request, { acceptsToken: 'session_token' }); + assertType(sessionAuth.userId); + + // All machine tokens should have id and subject + const apiKeyAuth = getAuth(request, { acceptsToken: 'api_key' }); + const machineTokenAuth = getAuth(request, { acceptsToken: 'machine_token' }); + const oauthTokenAuth = getAuth(request, { acceptsToken: 'oauth_token' }); + + assertType(apiKeyAuth.id); + assertType(machineTokenAuth.id); + assertType(oauthTokenAuth.id); + assertType(apiKeyAuth.subject); + assertType(machineTokenAuth.subject); + assertType(oauthTokenAuth.subject); + + // Only api_key and machine_token should have name and claims + assertType(apiKeyAuth.name); + assertType | null>(apiKeyAuth.claims); + + assertType(machineTokenAuth.name); + assertType | null>(machineTokenAuth.claims); + + // oauth_token should NOT have name and claims + // @ts-expect-error oauth_token does not have name property + void oauthTokenAuth.name; + // @ts-expect-error oauth_token does not have claims property + void oauthTokenAuth.claims; +}); + +test('verifies discriminated union works correctly with acceptsToken: any', () => { + const request = new Request('https://example.com'); + + const auth = getAuth(request, { acceptsToken: 'any' }); + + if (auth.tokenType === 'session_token') { + // Should be SessionAuthObject - has userId + assertType(auth.userId); + // Should NOT have machine token properties + // @ts-expect-error session_token does not have id property + void auth.id; + } else if (auth.tokenType === 'api_key') { + // Should be AuthenticatedMachineObject<'api_key'> - has id, name, claims + assertType(auth.id); + assertType(auth.name); + assertType | null>(auth.claims); + // Should NOT have session token properties + // @ts-expect-error api_key does not have userId property + void auth.userId; + } else if (auth.tokenType === 'machine_token') { + // Should be AuthenticatedMachineObject<'machine_token'> - has id, name, claims + assertType(auth.id); + assertType(auth.name); + assertType | null>(auth.claims); + // Should NOT have session token properties + // @ts-expect-error machine_token does not have userId property + void auth.userId; + } else if (auth.tokenType === 'oauth_token') { + // Should be AuthenticatedMachineObject<'oauth_token'> - has id but NOT name/claims + assertType(auth.id); + // Should NOT have name or claims + // @ts-expect-error oauth_token does not have name property + void auth.name; + // @ts-expect-error oauth_token does not have claims property + void auth.claims; + // Should NOT have session token properties + // @ts-expect-error oauth_token does not have userId property + void auth.userId; + } +}); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 97135c03865..410aef1e77d 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -104,29 +104,43 @@ type MachineObjectExtendedProperties = { /** * @internal + * + * Uses `T extends any` to create a distributive conditional type. + * This ensures that union types like `'api_key' | 'oauth_token'` are processed + * individually, creating proper discriminated unions where each token type + * gets its own distinct properties (e.g., oauth_token won't have claims). */ -export type AuthenticatedMachineObject = { - id: string; - subject: string; - scopes: string[]; - getToken: () => Promise; - has: CheckAuthorizationFromSessionClaims; - debug: AuthObjectDebug; - tokenType: T; -} & MachineObjectExtendedProperties[T]; +export type AuthenticatedMachineObject = T extends any + ? { + id: string; + subject: string; + scopes: string[]; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; + } & MachineObjectExtendedProperties[T] + : never; /** * @internal + * + * Uses `T extends any` to create a distributive conditional type. + * This ensures that union types like `'api_key' | 'oauth_token'` are processed + * individually, creating proper discriminated unions where each token type + * gets its own distinct properties (e.g., oauth_token won't have claims). */ -export type UnauthenticatedMachineObject = { - id: null; - subject: null; - scopes: null; - getToken: () => Promise; - has: CheckAuthorizationFromSessionClaims; - debug: AuthObjectDebug; - tokenType: T; -} & MachineObjectExtendedProperties[T]; +export type UnauthenticatedMachineObject = T extends any + ? { + id: null; + subject: null; + scopes: null; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; + } & MachineObjectExtendedProperties[T] + : never; /** * @interface @@ -243,7 +257,7 @@ export function authenticatedMachineObject( name: result.name, claims: result.claims, scopes: result.scopes, - }; + } as unknown as AuthenticatedMachineObject; } case TokenType.MachineToken: { const result = verificationResult as MachineToken; @@ -253,7 +267,7 @@ export function authenticatedMachineObject( name: result.name, claims: result.claims, scopes: result.scopes, - }; + } as unknown as AuthenticatedMachineObject; } case TokenType.OAuthToken: { return { @@ -290,7 +304,7 @@ export function unauthenticatedMachineObject( tokenType, name: null, claims: null, - }; + } as unknown as UnauthenticatedMachineObject; } case TokenType.MachineToken: { return { @@ -298,7 +312,7 @@ export function unauthenticatedMachineObject( tokenType, name: null, claims: null, - }; + } as unknown as UnauthenticatedMachineObject; } case TokenType.OAuthToken: { return { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index c87f1f3bc8b..badda7d18f3 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -163,8 +163,8 @@ export type InferAuthObjectFromTokenArray< > = SessionTokenType extends T[number] ? T[number] extends SessionTokenType ? SessionType - : SessionType | (MachineType & { tokenType: T[number] }) - : MachineType & { tokenType: T[number] }; + : SessionType | (MachineType & { tokenType: Exclude }) + : MachineType & { tokenType: Exclude }; /** * Infers auth object type from a single token type. @@ -174,12 +174,12 @@ export type InferAuthObjectFromToken< T extends TokenType, SessionType extends AuthObject, MachineType extends AuthObject, -> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: T }; +> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: Exclude }; export type SessionAuthObject = SignedInAuthObject | SignedOutAuthObject; -export type MachineAuthObject = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & { - tokenType: T; -}; +export type MachineAuthObject> = T extends any + ? AuthenticatedMachineObject | UnauthenticatedMachineObject + : never; type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; @@ -199,7 +199,10 @@ export interface GetAuthFn ( req: RequestType, options: AuthOptions & { acceptsToken: T }, - ): MaybePromise>, ReturnsPromise>; + ): MaybePromise< + InferAuthObjectFromTokenArray>>, + ReturnsPromise + >; /** * @example @@ -208,7 +211,10 @@ export interface GetAuthFn ( req: RequestType, options: AuthOptions & { acceptsToken: T }, - ): MaybePromise>, ReturnsPromise>; + ): MaybePromise< + InferAuthObjectFromToken>>, + ReturnsPromise + >; /** * @example diff --git a/packages/backend/vitest.config.mts b/packages/backend/vitest.config.mts index c4904416012..c690ee913f2 100644 --- a/packages/backend/vitest.config.mts +++ b/packages/backend/vitest.config.mts @@ -3,6 +3,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [], test: { + typecheck: { + enabled: true, + include: ['**/*.test.ts'], + }, coverage: { provider: 'v8', }, diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 74ea1ff9051..ec5b151f7ee 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,11 +1,10 @@ -import type { AuthObject } from '@clerk/backend'; +import type { AuthObject, MachineAuthObject, SessionAuthObject } from '@clerk/backend'; import type { AuthenticateRequestOptions, InferAuthObjectFromToken, InferAuthObjectFromTokenArray, - MachineAuthObject, RedirectFun, - SessionAuthObject, + SessionTokenType, } from '@clerk/backend/internal'; import { constants, createClerkRequest, createRedirect, TokenType } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; @@ -56,7 +55,13 @@ export interface AuthFn> { */ ( options: AuthOptions & { acceptsToken: T }, - ): Promise, MachineAuthObject>>; + ): Promise< + InferAuthObjectFromTokenArray< + T, + SessionAuthWithRedirect, + MachineAuthObject> + > + >; /** * @example @@ -64,7 +69,9 @@ export interface AuthFn> { */ ( options: AuthOptions & { acceptsToken: T }, - ): Promise, MachineAuthObject>>; + ): Promise< + InferAuthObjectFromToken, MachineAuthObject>> + >; /** * @example diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index bdd73ce85ec..cfb0dea5628 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -73,6 +73,12 @@ export interface AuthProtect { options?: AuthProtectOptions & { token: T }, ): Promise>; + /** + * @example + * auth.protect({ token: 'any' }); + */ + (options?: AuthProtectOptions & { token: 'any' }): Promise; + /** * @example * auth.protect();