diff --git a/.changeset/free-times-refuse.md b/.changeset/free-times-refuse.md new file mode 100644 index 00000000000..f2e04a6f787 --- /dev/null +++ b/.changeset/free-times-refuse.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': patch +'@clerk/nextjs': patch +--- + +Re-organize internal types for the recently added "machine authentication" feature. diff --git a/.changeset/large-adults-juggle.md b/.changeset/large-adults-juggle.md new file mode 100644 index 00000000000..0b123b04be6 --- /dev/null +++ b/.changeset/large-adults-juggle.md @@ -0,0 +1,30 @@ +--- +'@clerk/tanstack-react-start': minor +--- + +Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification. + +You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens. + +Example usage: + +```ts +import { createServerFn } from '@tanstack/react-start' +import { getAuth } from '@clerk/tanstack-react-start/server' +import { getWebRequest } from '@tanstack/react-start/server' + +const authStateFn = createServerFn({ method: 'GET' }).handler(async () => { + const request = getWebRequest() + const auth = await getAuth(request, { acceptsToken: 'any' }) + + if (authObject.tokenType === 'session_token') { + console.log('this is session token from a user') + } else { + console.log('this is some other type of machine token') + console.log('more specifically, a ' + authObject.tokenType) + } + + return {} +}) + +``` \ No newline at end of file diff --git a/.changeset/sour-onions-wear.md b/.changeset/sour-onions-wear.md new file mode 100644 index 00000000000..5aff6bbb6bf --- /dev/null +++ b/.changeset/sour-onions-wear.md @@ -0,0 +1,27 @@ +--- +'@clerk/express': minor +--- + +Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification. + +You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens. + +Example usage: + +```ts +import express from 'express'; +import { getAuth } from '@clerk/express'; + +const app = express(); + +app.get('/path', (req, res) => { + const authObject = getAuth(req, { acceptsToken: 'any' }); + + if (authObject.tokenType === 'session_token') { + console.log('this is session token from a user') + } else { + console.log('this is some other type of machine token') + console.log('more specifically, a ' + authObject.tokenType) + } +}); +``` \ No newline at end of file diff --git a/.changeset/two-trains-pull.md b/.changeset/two-trains-pull.md new file mode 100644 index 00000000000..7a82c62936d --- /dev/null +++ b/.changeset/two-trains-pull.md @@ -0,0 +1,27 @@ +--- +'@clerk/react-router': minor +--- + +Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification. + +You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens. + +Example usage: + +```ts +import { getAuth } from '@clerk/react-router/ssr.server' +import type { Route } from './+types/profile' + +export async function loader(args: Route.LoaderArgs) { + const authObject = await getAuth(args, { acceptsToken: 'any' }) + + if (authObject.tokenType === 'session_token') { + console.log('this is session token from a user') + } else { + console.log('this is some other type of machine token') + console.log('more specifically, a ' + authObject.tokenType) + } + + return {} +} +``` \ No newline at end of file diff --git a/.changeset/yummy-socks-join.md b/.changeset/yummy-socks-join.md new file mode 100644 index 00000000000..c0d107bcbf1 --- /dev/null +++ b/.changeset/yummy-socks-join.md @@ -0,0 +1,27 @@ +--- +'@clerk/fastify': minor +--- + +Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification. + +You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens. + +Example usage: + +```ts +import Fastify from 'fastify' +import { getAuth } from '@clerk/fastify' + +const fastify = Fastify() + +fastify.get('/path', (request, reply) => { + const authObject = getAuth(req, { acceptsToken: 'any' }); + + if (authObject.tokenType === 'session_token') { + console.log('this is session token from a user') + } else { + console.log('this is some other type of machine token') + console.log('more specifically, a ' + authObject.tokenType) + } +}); +``` \ No newline at end of file diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index defe2e5b11b..3d5ad36e5b4 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -150,7 +150,10 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "backend/client.mdx", "backend/email-address.mdx", "backend/external-account.mdx", + "backend/get-auth-fn.mdx", "backend/identification-link.mdx", + "backend/infer-auth-object-from-token-array.mdx", + "backend/infer-auth-object-from-token.mdx", "backend/invitation-status.mdx", "backend/invitation.mdx", "backend/organization-invitation-status.mdx", diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 2fb64cc513f..a9fa7faf256 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -47,6 +47,7 @@ describe('subpath /internal exports', () => { "createRedirect", "debugRequestState", "decorateObjectWithResources", + "getAuthObjectForAcceptedToken", "getAuthObjectFromJwt", "getMachineTokenType", "isMachineToken", diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index a4b931f2bef..fc729046e19 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -7,7 +7,15 @@ export { createAuthenticateRequest } from './tokens/factory'; export { debugRequestState } from './tokens/request'; -export type { AuthenticateRequestOptions, OrganizationSyncOptions } from './tokens/types'; +export type { + AuthenticateRequestOptions, + OrganizationSyncOptions, + InferAuthObjectFromToken, + InferAuthObjectFromTokenArray, + SessionAuthObject, + MachineAuthObject, + GetAuthFn, +} from './tokens/types'; export { TokenType } from './tokens/tokenTypes'; export type { SessionTokenType, MachineTokenType } from './tokens/tokenTypes'; @@ -26,6 +34,7 @@ export { authenticatedMachineObject, unauthenticatedMachineObject, getAuthObjectFromJwt, + getAuthObjectForAcceptedToken, } from './tokens/authObjects'; export { AuthStatus } from './tokens/authStatus'; diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 02969cc18dc..97135c03865 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -13,10 +13,11 @@ import type { import type { APIKey, CreateBackendApiOptions, MachineToken } from '../api'; import { createBackendApiClient } from '../api'; +import { isTokenTypeAccepted } from '../internal'; import type { AuthenticateContext } from './authenticateContext'; import type { MachineTokenType, SessionTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; -import type { MachineAuthType } from './types'; +import type { AuthenticateRequestOptions, MachineAuthType } from './types'; /** * @inline @@ -361,3 +362,63 @@ export const getAuthObjectFromJwt = ( return authObject; }; + +/** + * @internal + * Filters and coerces an AuthObject based on the accepted token type(s). + * + * This function is used after authentication to ensure that the returned auth object + * matches the expected token type(s) specified by `acceptsToken`. If the token type + * of the provided `authObject` does not match any of the types in `acceptsToken`, + * it returns an unauthenticated or signed-out version of the object, depending on the token type. + * + * - If `acceptsToken` is `'any'`, the original auth object is returned. + * - If `acceptsToken` is a single token type or an array of token types, the function checks if + * `authObject.tokenType` matches any of them. + * - If the token type does not match and is a session token, a signed-out object is returned. + * - If the token type does not match and is a machine token, an unauthenticated machine object is returned. + * - If the token type matches, the original auth object is returned. + * + * @param {Object} params + * @param {AuthObject} params.authObject - The authenticated object to filter. + * @param {AuthenticateRequestOptions['acceptsToken']} [params.acceptsToken=TokenType.SessionToken] - The accepted token type(s). Can be a string, array of strings, or 'any'. + * @returns {AuthObject} The filtered or coerced auth object. + * + * @example + * // Accept only 'api_key' tokens + * const authObject = { tokenType: 'session_token', userId: 'user_123' }; + * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: 'api_key' }); + * // result will be a signed-out object (since tokenType is 'session_token' and does not match) + * + * @example + * // Accept 'api_key' or 'machine_token' + * const authObject = { tokenType: 'machine_token', id: 'mt_123' }; + * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: ['api_key', 'machine_token'] }); + * // result will be the original authObject (since tokenType matches one in the array) + * + * @example + * // Accept any token type + * const authObject = { tokenType: 'api_key', id: 'ak_123' }; + * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: 'any' }); + * // result will be the original authObject + */ +export function getAuthObjectForAcceptedToken({ + authObject, + acceptsToken = TokenType.SessionToken, +}: { + authObject: AuthObject; + acceptsToken: AuthenticateRequestOptions['acceptsToken']; +}): AuthObject { + if (acceptsToken === 'any') { + return authObject; + } + + if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { + if (authObject.tokenType === TokenType.SessionToken) { + return signedOutAuthObject(authObject.debug); + } + return unauthenticatedMachineObject(authObject.tokenType, authObject.debug); + } + + return authObject; +} diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 055e4fce83b..c87f1f3bc8b 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -1,7 +1,15 @@ import type { MatchFunction } from '@clerk/shared/pathToRegexp'; +import type { PendingSessionOptions } from '@clerk/types'; import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; -import type { TokenType } from './tokenTypes'; +import type { + AuthenticatedMachineObject, + AuthObject, + SignedInAuthObject, + SignedOutAuthObject, + UnauthenticatedMachineObject, +} from './authObjects'; +import type { SessionTokenType, TokenType } from './tokenTypes'; import type { VerifyTokenOptions } from './verify'; /** @@ -141,3 +149,76 @@ export type OrganizationSyncTargetMatchers = { export type OrganizationSyncTarget = | { type: 'personalAccount' } | { type: 'organization'; organizationId?: string; organizationSlug?: string }; + +/** + * Infers auth object type from an array of token types. + * - Session token only -> SessionType + * - Mixed tokens -> SessionType | MachineType + * - Machine tokens only -> MachineType + */ +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: T[number] }) + : MachineType & { tokenType: T[number] }; + +/** + * Infers auth object type from a single token type. + * Returns SessionType for session tokens, or MachineType for machine tokens. + */ +export type InferAuthObjectFromToken< + T extends TokenType, + SessionType extends AuthObject, + MachineType extends AuthObject, +> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: T }; + +export type SessionAuthObject = SignedInAuthObject | SignedOutAuthObject; +export type MachineAuthObject = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & { + tokenType: T; +}; + +type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +type MaybePromise = IsPromise extends true ? Promise : T; + +/** + * Shared generic overload type for getAuth() helpers across SDKs. + * + * - Parameterized by the request type (RequestType). + * - Handles different accepted token types and their corresponding return types. + */ +export interface GetAuthFn { + /** + * @example + * const auth = await getAuth(req, { acceptsToken: ['session_token', 'api_key'] }) + */ + ( + req: RequestType, + options: AuthOptions & { acceptsToken: T }, + ): MaybePromise>, ReturnsPromise>; + + /** + * @example + * const auth = await getAuth(req, { acceptsToken: 'session_token' }) + */ + ( + req: RequestType, + options: AuthOptions & { acceptsToken: T }, + ): MaybePromise>, ReturnsPromise>; + + /** + * @example + * const auth = await getAuth(req, { acceptsToken: 'any' }) + */ + (req: RequestType, options: AuthOptions & { acceptsToken: 'any' }): MaybePromise; + + /** + * @example + * const auth = await getAuth(req) + */ + (req: RequestType, options?: PendingSessionOptions): MaybePromise; +} diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts index eaa3e6337ba..bcce23ccd83 100644 --- a/packages/express/src/__tests__/getAuth.test.ts +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -2,16 +2,46 @@ import { getAuth } from '../getAuth'; import { mockRequest, mockRequestWithAuth } from './helpers'; describe('getAuth', () => { - it('throws error if clerkMiddleware is not executed before getAuth', async () => { + it('throws error if clerkMiddleware is not executed before getAuth', () => { expect(() => getAuth(mockRequest())).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/); }); - it('returns auth from request for signed-out request', async () => { - expect(getAuth(mockRequestWithAuth())).toHaveProperty('userId', null); + it('returns auth from request for signed-out request', () => { + const req = mockRequestWithAuth({ userId: null, tokenType: 'session_token' }); + const auth = getAuth(req); + expect(auth.userId).toBeNull(); + expect(auth.tokenType).toBe('session_token'); }); - it('returns auth from request', async () => { - const req = mockRequestWithAuth({ userId: 'user_12345' }); - expect(getAuth(req)).toHaveProperty('userId', 'user_12345'); + it('returns auth from request', () => { + const req = mockRequestWithAuth({ userId: 'user_12345', tokenType: 'session_token' }); + const auth = getAuth(req); + expect(auth.userId).toBe('user_12345'); + expect(auth.tokenType).toBe('session_token'); + }); + + it('returns the actual auth object when its tokenType matches acceptsToken', () => { + const req = mockRequestWithAuth({ tokenType: 'api_key', id: 'ak_1234', subject: 'api_key_1234' }); + const result = getAuth(req, { acceptsToken: 'api_key' }); + expect(result.tokenType).toBe('api_key'); + expect(result.id).toBe('ak_1234'); + expect(result.subject).toBe('api_key_1234'); + }); + + it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { + const req = mockRequestWithAuth({ tokenType: 'machine_token', id: 'mt_1234' }); + const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); + expect(result.tokenType).toBe('machine_token'); + expect(result.id).toBe('mt_1234'); + expect(result.subject).toBeUndefined(); + }); + + it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { + const req = mockRequestWithAuth({ tokenType: 'session_token', userId: 'user_12345' }); + const result = getAuth(req, { acceptsToken: 'api_key' }); + expect(result.tokenType).toBe('session_token'); // reflects the actual token found + // Properties specific to authenticated objects should be null or undefined + // @ts-expect-error - userId is not a property of the unauthenticated object + expect(result.userId).toBeNull(); }); }); diff --git a/packages/express/src/__tests__/helpers.ts b/packages/express/src/__tests__/helpers.ts index 125f0afd2e0..d82bcc2b26d 100644 --- a/packages/express/src/__tests__/helpers.ts +++ b/packages/express/src/__tests__/helpers.ts @@ -1,4 +1,4 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { AuthObject } from '@clerk/backend'; import type { Application, Request as ExpressRequest, RequestHandler, Response as ExpressResponse } from 'express'; import express from 'express'; import supertest from 'supertest'; @@ -26,22 +26,13 @@ export function mockRequest(): ExpressRequest { return {} as ExpressRequest; } -export function mockRequestWithAuth( - auth: Partial = {}, -): ExpressRequestWithAuth { +export function mockRequestWithAuth(auth: Partial = {}): ExpressRequestWithAuth { return { auth: () => ({ - sessionClaims: null, - sessionId: null, - actor: null, - userId: null, - orgId: null, - orgRole: null, - orgSlug: null, - orgPermissions: null, - getToken: async () => '', + getToken: () => Promise.resolve(''), has: () => false, debug: () => ({}), + tokenType: 'session_token', ...auth, }), } as unknown as ExpressRequestWithAuth; diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 88e014f9fdc..c74a88c40f4 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -13,7 +13,7 @@ import { incomingMessageToRequest, loadApiEnv, loadClientEnv } from './utils'; export const authenticateRequest = (opts: AuthenticateRequestParams) => { const { clerkClient, request, options } = opts; - const { jwtKey, authorizedParties, audience } = options || {}; + const { jwtKey, authorizedParties, audience, acceptsToken } = options || {}; const clerkRequest = createClerkRequest(incomingMessageToRequest(request)); const env = { ...loadApiEnv(), ...loadClientEnv() }; @@ -47,6 +47,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { isSatellite, domain, signInUrl, + acceptsToken, }); }; @@ -116,11 +117,13 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = const authHandler = (opts: Parameters[0]) => requestState.toAuth(opts); const authObject = requestState.toAuth(); - const auth = new Proxy(Object.assign(authHandler, authObject), { - get(target, prop: string, receiver) { + const auth = new Proxy(authHandler, { + get(target, prop, receiver) { deprecated('req.auth', 'Use `req.auth()` as a function instead.'); - - return Reflect.get(target, prop, receiver); + // If the property exists on the function, return it + if (prop in target) return Reflect.get(target, prop, receiver); + // Otherwise, get it from the authObject + return authObject?.[prop as keyof typeof authObject]; }, }); diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts index 13f99d27abc..ac360542697 100644 --- a/packages/express/src/clerkMiddleware.ts +++ b/packages/express/src/clerkMiddleware.ts @@ -19,7 +19,10 @@ import type { ClerkMiddlewareOptions } from './types'; * app.use(clerkMiddleware()); */ export const clerkMiddleware = (options: ClerkMiddlewareOptions = {}): RequestHandler => { - const authMiddleware = authenticateAndDecorateRequest(options); + const authMiddleware = authenticateAndDecorateRequest({ + ...options, + acceptsToken: 'any', + }); return (request, response, next) => { authMiddleware(request, response, next); diff --git a/packages/express/src/getAuth.ts b/packages/express/src/getAuth.ts index 3411b705c0a..7b2dfdba9a6 100644 --- a/packages/express/src/getAuth.ts +++ b/packages/express/src/getAuth.ts @@ -1,11 +1,12 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { AuthenticateRequestOptions, GetAuthFn } from '@clerk/backend/internal'; +import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; import type { Request as ExpressRequest } from 'express'; import { middlewareRequired } from './errors'; import { requestHasAuthObject } from './utils'; -type GetAuthOptions = PendingSessionOptions; +type GetAuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; /** * Retrieves the Clerk AuthObject using the current request object. @@ -14,10 +15,12 @@ type GetAuthOptions = PendingSessionOptions; * @returns {AuthObject} Object with information about the request state and claims. * @throws {Error} `clerkMiddleware` or `requireAuth` is required to be set in the middleware chain before this util is used. */ -export const getAuth = (req: ExpressRequest, options?: GetAuthOptions): SignedInAuthObject | SignedOutAuthObject => { +export const getAuth: GetAuthFn = ((req: ExpressRequest, options?: GetAuthOptions) => { if (!requestHasAuthObject(req)) { throw new Error(middlewareRequired('getAuth')); } - return req.auth(options); -}; + const authObject = req.auth(options); + + return getAuthObjectForAcceptedToken({ authObject, acceptsToken: options?.acceptsToken }); +}) as GetAuthFn; diff --git a/packages/express/src/requireAuth.ts b/packages/express/src/requireAuth.ts index c1ad5a7c353..019634331dd 100644 --- a/packages/express/src/requireAuth.ts +++ b/packages/express/src/requireAuth.ts @@ -33,7 +33,10 @@ import type { ClerkMiddlewareOptions, ExpressRequestWithAuth } from './types'; * router.get('/path', requireAuth(), hasPermission, getHandler) */ export const requireAuth = (options: ClerkMiddlewareOptions = {}): RequestHandler => { - const authMiddleware = authenticateAndDecorateRequest(options); + const authMiddleware = authenticateAndDecorateRequest({ + ...options, + acceptsToken: 'any', + }); return (request, response, next) => { authMiddleware(request, response, err => { diff --git a/packages/fastify/src/__tests__/getAuth.test.ts b/packages/fastify/src/__tests__/getAuth.test.ts index 71236551b4e..1b7aaad4ccd 100644 --- a/packages/fastify/src/__tests__/getAuth.test.ts +++ b/packages/fastify/src/__tests__/getAuth.test.ts @@ -4,14 +4,39 @@ import { getAuth } from '../getAuth'; describe('getAuth(req)', () => { test('returns req.auth', () => { - const req = { key1: 'asa', auth: 'authObj' } as any as FastifyRequest; + const req = { key1: 'asa', auth: { tokenType: 'session_token' } } as unknown as FastifyRequest; - expect(getAuth(req)).toEqual('authObj'); + expect(getAuth(req)).toEqual({ tokenType: 'session_token' }); }); test('throws error if clerkPlugin is on registered', () => { - const req = { key1: 'asa' } as any as FastifyRequest; + const req = { key1: 'asa' } as unknown as FastifyRequest; expect(() => getAuth(req)).toThrowErrorMatchingSnapshot(); }); + + it('returns the actual auth object when its tokenType matches acceptsToken', () => { + const req = { auth: { tokenType: 'api_key', id: 'ak_1234', subject: 'api_key_1234' } } as unknown as FastifyRequest; + const result = getAuth(req, { acceptsToken: 'api_key' }); + expect(result.tokenType).toBe('api_key'); + expect(result.id).toBe('ak_1234'); + expect(result.subject).toBe('api_key_1234'); + }); + + it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { + const req = { auth: { tokenType: 'machine_token', id: 'mt_1234' } } as unknown as FastifyRequest; + const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); + expect(result.tokenType).toBe('machine_token'); + expect(result.id).toBe('mt_1234'); + expect(result.subject).toBeUndefined(); + }); + + it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { + const req = { auth: { tokenType: 'session_token', userId: 'user_12345' } } as unknown as FastifyRequest; + const result = getAuth(req, { acceptsToken: 'api_key' }); + expect(result.tokenType).toBe('session_token'); // reflects the actual token found + // Properties specific to authenticated objects should be null or undefined + // @ts-expect-error - userId is not a property of the unauthenticated object + expect(result.userId).toBeNull(); + }); }); diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index c918437b341..69aa9188cfc 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -25,7 +25,9 @@ describe('withClerkMiddleware(options)', () => { test('handles signin with Authorization Bearer', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), - toAuth: () => 'mockedAuth', + toAuth: () => ({ + tokenType: 'session_token', + }), }); const fastify = Fastify(); await fastify.register(clerkPlugin); @@ -50,7 +52,7 @@ describe('withClerkMiddleware(options)', () => { }); expect(response.statusCode).toEqual(200); - expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); + expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); expect(authenticateRequestMock).toBeCalledWith( expect.any(Request), expect.objectContaining({ @@ -62,7 +64,9 @@ describe('withClerkMiddleware(options)', () => { test('handles signin with cookie', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), - toAuth: () => 'mockedAuth', + toAuth: () => ({ + tokenType: 'session_token', + }), }); const fastify = Fastify(); await fastify.register(clerkPlugin); @@ -87,7 +91,7 @@ describe('withClerkMiddleware(options)', () => { }); expect(response.statusCode).toEqual(200); - expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); + expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); expect(authenticateRequestMock).toBeCalledWith( expect.any(Request), expect.objectContaining({ @@ -107,7 +111,9 @@ describe('withClerkMiddleware(options)', () => { 'x-clerk-auth-reason': 'auth-reason', 'x-clerk-auth-status': 'handshake', }), - toAuth: () => 'mockedAuth', + toAuth: () => ({ + tokenType: 'session_token', + }), }); const fastify = Fastify(); await fastify.register(clerkPlugin); @@ -137,7 +143,9 @@ describe('withClerkMiddleware(options)', () => { test('handles signout case by populating the req.auth', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), - toAuth: () => 'mockedAuth', + toAuth: () => ({ + tokenType: 'session_token', + }), }); const fastify = Fastify(); await fastify.register(clerkPlugin); @@ -154,7 +162,7 @@ describe('withClerkMiddleware(options)', () => { }); expect(response.statusCode).toEqual(200); - expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); + expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); expect(authenticateRequestMock).toBeCalledWith( expect.any(Request), expect.objectContaining({ diff --git a/packages/fastify/src/getAuth.ts b/packages/fastify/src/getAuth.ts index 1707572dfd6..7d56567845c 100644 --- a/packages/fastify/src/getAuth.ts +++ b/packages/fastify/src/getAuth.ts @@ -1,16 +1,22 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { + AuthenticateRequestOptions, + GetAuthFn, + SignedInAuthObject, + SignedOutAuthObject, +} from '@clerk/backend/internal'; +import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; import type { FastifyRequest } from 'fastify'; import { pluginRegistrationRequired } from './errors'; -type FastifyRequestWithAuth = FastifyRequest & { auth: SignedInAuthObject | SignedOutAuthObject }; +type GetAuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; -export const getAuth = (req: FastifyRequest): SignedInAuthObject | SignedOutAuthObject => { - const authReq = req as FastifyRequestWithAuth; +export const getAuth: GetAuthFn = ((req: FastifyRequest, options?: GetAuthOptions) => { + const authReq = req as FastifyRequest & { auth: SignedInAuthObject | SignedOutAuthObject }; if (!authReq.auth) { throw new Error(pluginRegistrationRequired); } - return authReq.auth; -}; + return getAuthObjectForAcceptedToken({ authObject: authReq.auth, acceptsToken: options?.acceptsToken }); +}) as GetAuthFn; diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index b9c2abf6ab8..8679e912cfb 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -14,6 +14,7 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { ...options, secretKey: options.secretKey || constants.SECRET_KEY, publishableKey: options.publishableKey || constants.PUBLISHABLE_KEY, + acceptsToken: 'any', }); requestState.headers.forEach((value, key) => reply.header(key, value)); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index ce808df80f3..74ea1ff9051 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,11 +1,11 @@ import type { AuthObject } from '@clerk/backend'; import type { - AuthenticatedMachineObject, AuthenticateRequestOptions, + InferAuthObjectFromToken, + InferAuthObjectFromTokenArray, + MachineAuthObject, RedirectFun, - SignedInAuthObject, - SignedOutAuthObject, - UnauthenticatedMachineObject, + SessionAuthObject, } from '@clerk/backend/internal'; import { constants, createClerkRequest, createRedirect, TokenType } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; @@ -18,7 +18,6 @@ import { getAuthKeyFromRequest, getHeader } from '../../server/headers-utils'; import { unauthorized } from '../../server/nextErrors'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; -import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from '../../server/types'; import { decryptClerkRequestData } from '../../server/utils'; import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { buildRequestLike } from './utils'; @@ -26,7 +25,7 @@ import { buildRequestLike } from './utils'; /** * `Auth` object of the currently active user and the `redirectToSignIn()` method. */ -type SessionAuth = (SignedInAuthObject | SignedOutAuthObject) & { +type SessionAuthWithRedirect = SessionAuthObject & { /** * The `auth()` helper returns the `redirectToSignIn()` method, which you can use to redirect the user to the sign-in page. * @@ -48,10 +47,6 @@ type SessionAuth = (SignedInAuthObject | SignedOutAuthObject) & { redirectToSignUp: RedirectFun; }; -type MachineAuth = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & { - tokenType: T; -}; - export type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; export interface AuthFn> { @@ -61,7 +56,7 @@ export interface AuthFn> { */ ( options: AuthOptions & { acceptsToken: T }, - ): Promise, MachineAuth>>; + ): Promise, MachineAuthObject>>; /** * @example @@ -69,7 +64,7 @@ export interface AuthFn> { */ ( options: AuthOptions & { acceptsToken: T }, - ): Promise, MachineAuth>>; + ): Promise, MachineAuthObject>>; /** * @example @@ -81,7 +76,7 @@ export interface AuthFn> { * @example * const authObject = await auth() */ - (options?: PendingSessionOptions): Promise>; + (options?: PendingSessionOptions): Promise>; /** * `auth` includes a single property, the `protect()` method, which you can use in two ways: diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 8dd50fb050e..bdd73ce85ec 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -2,6 +2,8 @@ import type { AuthObject } from '@clerk/backend'; import type { AuthenticatedMachineObject, AuthenticateRequestOptions, + InferAuthObjectFromToken, + InferAuthObjectFromTokenArray, RedirectFun, SignedInAuthObject, } from '@clerk/backend/internal'; @@ -16,7 +18,6 @@ import type { import { constants as nextConstants } from '../constants'; import { isNextFetcher } from './nextFetcher'; -import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from './types'; type AuthProtectOptions = { /** diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index 347827983ad..b1f15bc79fd 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -1,5 +1,3 @@ -import type { AuthObject } from '@clerk/backend'; -import type { SessionTokenType, TokenType } from '@clerk/backend/internal'; import type { IncomingMessage } from 'http'; import type { NextApiRequest } from 'next'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; @@ -13,29 +11,3 @@ export type RequestLike = NextRequest | NextApiRequest | GsspRequest; export type NextMiddlewareRequestParam = Parameters['0']; export type NextMiddlewareEvtParam = Parameters['1']; export type NextMiddlewareReturn = ReturnType; - -/** - * Infers auth object type from an array of token types. - * - Session token only -> SessionType - * - Mixed tokens -> SessionType | MachineType - * - Machine tokens only -> MachineType - */ -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: T[number] }) - : MachineType & { tokenType: T[number] }; - -/** - * Infers auth object type from a single token type. - * Returns SessionType for session tokens, or MachineType for machine tokens. - */ -export type InferAuthObjectFromToken< - T extends TokenType, - SessionType extends AuthObject, - MachineType extends AuthObject, -> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: T }; diff --git a/packages/react-router/src/ssr/getAuth.ts b/packages/react-router/src/ssr/getAuth.ts index 5113f2e1877..bc488619444 100644 --- a/packages/react-router/src/ssr/getAuth.ts +++ b/packages/react-router/src/ssr/getAuth.ts @@ -1,21 +1,38 @@ -import { stripPrivateDataFromObject } from '@clerk/backend/internal'; +import { + type AuthenticateRequestOptions, + type GetAuthFn, + getAuthObjectForAcceptedToken, +} from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; import { authenticateRequest } from './authenticateRequest'; import { loadOptions } from './loadOptions'; -import type { GetAuthReturn, RootAuthLoaderOptions } from './types'; +import type { RootAuthLoaderOptions } from './types'; -type GetAuthOptions = Pick; +type GetAuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] } & Pick< + RootAuthLoaderOptions, + 'secretKey' +>; -export async function getAuth(args: LoaderFunctionArgs, opts?: GetAuthOptions): GetAuthReturn { +export const getAuth: GetAuthFn = (async ( + args: LoaderFunctionArgs, + opts?: GetAuthOptions, +) => { if (!args || (args && (!args.request || !args.context))) { throw new Error(noLoaderArgsPassedInGetAuth); } - const loadedOptions = loadOptions(args, opts); + const { acceptsToken, ...restOptions } = opts || {}; + + const loadedOptions = loadOptions(args, restOptions); // Note: authenticateRequest() will throw a redirect if the auth state is determined to be handshake - const requestState = await authenticateRequest(args, loadedOptions); + const requestState = await authenticateRequest(args, { + ...loadedOptions, + acceptsToken: 'any', + }); + + const authObject = requestState.toAuth(); - return stripPrivateDataFromObject(requestState.toAuth()); -} + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); +}) as GetAuthFn; diff --git a/packages/tanstack-react-start/src/server/authenticateRequest.ts b/packages/tanstack-react-start/src/server/authenticateRequest.ts index 4852d4544be..94dd774952b 100644 --- a/packages/tanstack-react-start/src/server/authenticateRequest.ts +++ b/packages/tanstack-react-start/src/server/authenticateRequest.ts @@ -1,5 +1,5 @@ import { createClerkClient } from '@clerk/backend'; -import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal'; +import type { AuthenticatedState, AuthenticateRequestOptions, UnauthenticatedState } from '@clerk/backend/internal'; import { AuthStatus, constants } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; @@ -9,10 +9,10 @@ import { patchRequest } from './utils'; export async function authenticateRequest( request: Request, opts: AuthenticateRequestOptions, -): Promise { +): Promise { const { audience, authorizedParties } = opts; - const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey } = opts; + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, acceptsToken } = opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; const requestState = await createClerkClient({ @@ -31,6 +31,7 @@ export async function authenticateRequest( signUpUrl, afterSignInUrl, afterSignUpUrl, + acceptsToken, }); const locationHeader = requestState.headers.get(constants.Headers.Location); diff --git a/packages/tanstack-react-start/src/server/getAuth.ts b/packages/tanstack-react-start/src/server/getAuth.ts index f2edff0f610..c7acf0740e1 100644 --- a/packages/tanstack-react-start/src/server/getAuth.ts +++ b/packages/tanstack-react-start/src/server/getAuth.ts @@ -1,5 +1,5 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; -import { stripPrivateDataFromObject } from '@clerk/backend/internal'; +import type { AuthenticateRequestOptions, GetAuthFn } from '@clerk/backend/internal'; +import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; import { errorThrower } from '../utils'; import { noFetchFnCtxPassedInGetAuth } from '../utils/errors'; @@ -7,18 +7,23 @@ import { authenticateRequest } from './authenticateRequest'; import { loadOptions } from './loadOptions'; import type { LoaderOptions } from './types'; -type GetAuthReturn = Promise; +type GetAuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] } & Pick; -type GetAuthOptions = Pick; - -export async function getAuth(request: Request, opts?: GetAuthOptions): GetAuthReturn { +export const getAuth: GetAuthFn = (async (request: Request, opts?: GetAuthOptions) => { if (!request) { return errorThrower.throw(noFetchFnCtxPassedInGetAuth); } - const loadedOptions = loadOptions(request, opts); + const { acceptsToken, ...restOptions } = opts || {}; + + const loadedOptions = loadOptions(request, restOptions); + + const requestState = await authenticateRequest(request, { + ...loadedOptions, + acceptsToken: 'any', + }); - const requestState = await authenticateRequest(request, loadedOptions); + const authObject = requestState.toAuth(); - return stripPrivateDataFromObject(requestState.toAuth()); -} + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); +}) as GetAuthFn; diff --git a/packages/tanstack-react-start/src/server/middlewareHandler.ts b/packages/tanstack-react-start/src/server/middlewareHandler.ts index 21994cdf61c..55e011960c7 100644 --- a/packages/tanstack-react-start/src/server/middlewareHandler.ts +++ b/packages/tanstack-react-start/src/server/middlewareHandler.ts @@ -1,5 +1,5 @@ import type { AnyRouter } from '@tanstack/react-router'; -import type { EventHandler } from '@tanstack/react-start/server'; +import { type EventHandler } from '@tanstack/react-start/server'; import { authenticateRequest } from './authenticateRequest'; import { loadOptions } from './loadOptions'; @@ -22,7 +22,10 @@ export function createClerkHandler( try { const loadedOptions = loadOptions(request, clerkOptions); - const requestState = await authenticateRequest(request, loadedOptions); + const requestState = await authenticateRequest(request, { + ...loadedOptions, + acceptsToken: 'any', + }); const { clerkInitialState, headers } = getResponseClerkState(requestState, loadedOptions);