Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/social-carrots-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/backend': patch
'@clerk/nextjs': patch
---

Resolve machine token property mixing in discriminated unions
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,4 @@ export type {
* Auth objects
*/
export type { AuthObject } from './tokens/authObjects';
export type { SessionAuthObject, MachineAuthObject } from './tokens/types';
2 changes: 0 additions & 2 deletions packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export type {
OrganizationSyncOptions,
InferAuthObjectFromToken,
InferAuthObjectFromTokenArray,
SessionAuthObject,
MachineAuthObject,
GetAuthFn,
} from './tokens/types';

Expand Down
107 changes: 107 additions & 0 deletions packages/backend/src/tokens/__tests__/getAuth.test-d.ts
Original file line number Diff line number Diff line change
@@ -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> = (_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<SessionAuthObject>(getAuth(request));

// Individual token types
assertType<SessionAuthObject>(getAuth(request, { acceptsToken: 'session_token' }));
assertType<MachineAuthObject<'api_key'>>(getAuth(request, { acceptsToken: 'api_key' }));
assertType<MachineAuthObject<'machine_token'>>(getAuth(request, { acceptsToken: 'machine_token' }));
assertType<MachineAuthObject<'oauth_token'>>(getAuth(request, { acceptsToken: 'oauth_token' }));

// Array of token types
assertType<SessionAuthObject | MachineAuthObject<'machine_token'>>(
getAuth(request, { acceptsToken: ['session_token', 'machine_token'] }),
);
assertType<MachineAuthObject<'machine_token' | 'oauth_token'>>(
getAuth(request, { acceptsToken: ['machine_token', 'oauth_token'] }),
);

// Any token type
assertType<AuthObject>(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<string | null>(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<string | null>(apiKeyAuth.id);
assertType<string | null>(machineTokenAuth.id);
assertType<string | null>(oauthTokenAuth.id);
assertType<string | null>(apiKeyAuth.subject);
assertType<string | null>(machineTokenAuth.subject);
assertType<string | null>(oauthTokenAuth.subject);

// Only api_key and machine_token should have name and claims
assertType<string | null>(apiKeyAuth.name);
assertType<Record<string, any> | null>(apiKeyAuth.claims);

assertType<string | null>(machineTokenAuth.name);
assertType<Record<string, any> | 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<string | null>(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<string | null>(auth.id);
assertType<string | null>(auth.name);
assertType<Record<string, any> | 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<string | null>(auth.id);
assertType<string | null>(auth.name);
assertType<Record<string, any> | 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<string | null>(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;
}
});
58 changes: 36 additions & 22 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,29 +104,43 @@ type MachineObjectExtendedProperties<TAuthenticated extends boolean> = {

/**
* @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<T extends MachineTokenType = MachineTokenType> = {
id: string;
subject: string;
scopes: string[];
getToken: () => Promise<string>;
has: CheckAuthorizationFromSessionClaims;
debug: AuthObjectDebug;
tokenType: T;
} & MachineObjectExtendedProperties<true>[T];
export type AuthenticatedMachineObject<T extends MachineTokenType = MachineTokenType> = T extends any
? {
id: string;
subject: string;
scopes: string[];
getToken: () => Promise<string>;
has: CheckAuthorizationFromSessionClaims;
debug: AuthObjectDebug;
tokenType: T;
} & MachineObjectExtendedProperties<true>[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<T extends MachineTokenType = MachineTokenType> = {
id: null;
subject: null;
scopes: null;
getToken: () => Promise<null>;
has: CheckAuthorizationFromSessionClaims;
debug: AuthObjectDebug;
tokenType: T;
} & MachineObjectExtendedProperties<false>[T];
export type UnauthenticatedMachineObject<T extends MachineTokenType = MachineTokenType> = T extends any
? {
id: null;
subject: null;
scopes: null;
getToken: () => Promise<null>;
has: CheckAuthorizationFromSessionClaims;
debug: AuthObjectDebug;
tokenType: T;
} & MachineObjectExtendedProperties<false>[T]
: never;

/**
* @interface
Expand Down Expand Up @@ -243,7 +257,7 @@ export function authenticatedMachineObject<T extends MachineTokenType>(
name: result.name,
claims: result.claims,
scopes: result.scopes,
};
} as unknown as AuthenticatedMachineObject<T>;
}
case TokenType.MachineToken: {
const result = verificationResult as MachineToken;
Expand All @@ -253,7 +267,7 @@ export function authenticatedMachineObject<T extends MachineTokenType>(
name: result.name,
claims: result.claims,
scopes: result.scopes,
};
} as unknown as AuthenticatedMachineObject<T>;
}
case TokenType.OAuthToken: {
return {
Expand Down Expand Up @@ -290,15 +304,15 @@ export function unauthenticatedMachineObject<T extends MachineTokenType>(
tokenType,
name: null,
claims: null,
};
} as unknown as UnauthenticatedMachineObject<T>;
}
case TokenType.MachineToken: {
return {
...baseObject,
tokenType,
name: null,
claims: null,
};
} as unknown as UnauthenticatedMachineObject<T>;
}
case TokenType.OAuthToken: {
return {
Expand Down
22 changes: 14 additions & 8 deletions packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T[number], SessionTokenType> })
: MachineType & { tokenType: Exclude<T[number], SessionTokenType> };

/**
* Infers auth object type from a single token type.
Expand All @@ -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<T, SessionTokenType> };

export type SessionAuthObject = SignedInAuthObject | SignedOutAuthObject;
export type MachineAuthObject<T extends TokenType> = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & {
tokenType: T;
};
export type MachineAuthObject<T extends Exclude<TokenType, SessionTokenType>> = T extends any
? AuthenticatedMachineObject<T> | UnauthenticatedMachineObject<T>
: never;

type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] };

Expand All @@ -199,7 +199,10 @@ export interface GetAuthFn<RequestType, ReturnsPromise extends boolean = false>
<T extends TokenType[]>(
req: RequestType,
options: AuthOptions & { acceptsToken: T },
): MaybePromise<InferAuthObjectFromTokenArray<T, SessionAuthObject, MachineAuthObject<T[number]>>, ReturnsPromise>;
): MaybePromise<
InferAuthObjectFromTokenArray<T, SessionAuthObject, MachineAuthObject<Exclude<T[number], SessionTokenType>>>,
ReturnsPromise
>;

/**
* @example
Expand All @@ -208,7 +211,10 @@ export interface GetAuthFn<RequestType, ReturnsPromise extends boolean = false>
<T extends TokenType>(
req: RequestType,
options: AuthOptions & { acceptsToken: T },
): MaybePromise<InferAuthObjectFromToken<T, SessionAuthObject, MachineAuthObject<T>>, ReturnsPromise>;
): MaybePromise<
InferAuthObjectFromToken<T, SessionAuthObject, MachineAuthObject<Exclude<T, SessionTokenType>>>,
ReturnsPromise
>;

/**
* @example
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [],
test: {
typecheck: {
enabled: true,
include: ['**/*.test.ts'],
},
coverage: {
provider: 'v8',
},
Expand Down
17 changes: 12 additions & 5 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,15 +55,23 @@ export interface AuthFn<TRedirect = ReturnType<typeof redirect>> {
*/
<T extends TokenType[]>(
options: AuthOptions & { acceptsToken: T },
): Promise<InferAuthObjectFromTokenArray<T, SessionAuthWithRedirect<TRedirect>, MachineAuthObject<T[number]>>>;
): Promise<
InferAuthObjectFromTokenArray<
T,
SessionAuthWithRedirect<TRedirect>,
MachineAuthObject<Exclude<T[number], SessionTokenType>>
>
>;

/**
* @example
* const authObject = await auth({ acceptsToken: 'session_token' })
*/
<T extends TokenType>(
options: AuthOptions & { acceptsToken: T },
): Promise<InferAuthObjectFromToken<T, SessionAuthWithRedirect<TRedirect>, MachineAuthObject<T>>>;
): Promise<
InferAuthObjectFromToken<T, SessionAuthWithRedirect<TRedirect>, MachineAuthObject<Exclude<T, SessionTokenType>>>
>;

/**
* @example
Expand Down
6 changes: 6 additions & 0 deletions packages/nextjs/src/server/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export interface AuthProtect {
options?: AuthProtectOptions & { token: T },
): Promise<InferAuthObjectFromTokenArray<T, SignedInAuthObject, AuthenticatedMachineObject>>;

/**
* @example
* auth.protect({ token: 'any' });
*/
(options?: AuthProtectOptions & { token: 'any' }): Promise<SignedInAuthObject | AuthenticatedMachineObject>;

/**
* @example
* auth.protect();
Expand Down
Loading