diff --git a/.changeset/good-eggs-retire.md b/.changeset/good-eggs-retire.md new file mode 100644 index 00000000000..4d788a16d52 --- /dev/null +++ b/.changeset/good-eggs-retire.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +`authenticateRequest()` will now set a refreshsed session cookie on the response when an expired session token is refreshed via the Clerk API. diff --git a/packages/backend/src/api/endpoints/SessionApi.ts b/packages/backend/src/api/endpoints/SessionApi.ts index 248362dbb85..4022e12512c 100644 --- a/packages/backend/src/api/endpoints/SessionApi.ts +++ b/packages/backend/src/api/endpoints/SessionApi.ts @@ -1,6 +1,7 @@ import type { ClerkPaginationRequest, SessionStatus } from '@clerk/types'; import { joinPaths } from '../../util/path'; +import type { Cookies } from '../resources/Cookies'; import type { PaginatedResourceResponse } from '../resources/Deserializer'; import type { Session } from '../resources/Session'; import type { Token } from '../resources/Token'; @@ -20,6 +21,8 @@ type RefreshTokenParams = { request_origin: string; request_originating_ip?: string; request_headers?: Record; + suffixed_cookies?: boolean; + format?: 'token' | 'cookie'; }; export class SessionAPI extends AbstractAPI { @@ -64,12 +67,17 @@ export class SessionAPI extends AbstractAPI { }); } - public async refreshSession(sessionId: string, params: RefreshTokenParams) { + public async refreshSession(sessionId: string, params: RefreshTokenParams & { format: 'token' }): Promise; + public async refreshSession(sessionId: string, params: RefreshTokenParams & { format: 'cookie' }): Promise; + public async refreshSession(sessionId: string, params: RefreshTokenParams): Promise; + public async refreshSession(sessionId: string, params: RefreshTokenParams): Promise { this.requireId(sessionId); - return this.request({ + const { suffixed_cookies, ...restParams } = params; + return this.request({ method: 'POST', path: joinPaths(basePath, sessionId, 'refresh'), - bodyParams: params, + bodyParams: restParams, + queryParams: { suffixed_cookies }, }); } } diff --git a/packages/backend/src/api/resources/Cookies.ts b/packages/backend/src/api/resources/Cookies.ts new file mode 100644 index 00000000000..bcb5087d07f --- /dev/null +++ b/packages/backend/src/api/resources/Cookies.ts @@ -0,0 +1,9 @@ +import type { CookiesJSON } from './JSON'; + +export class Cookies { + constructor(readonly cookies: string[]) {} + + static fromJSON(data: CookiesJSON): Cookies { + return new Cookies(data.cookies); + } +} diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 11a6a361644..067d0ee0693 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -1,6 +1,7 @@ import { AllowlistIdentifier, Client, + Cookies, DeletedObject, Email, EmailAddress, @@ -72,6 +73,8 @@ function jsonToObject(item: any): any { return AllowlistIdentifier.fromJSON(item); case ObjectType.Client: return Client.fromJSON(item); + case ObjectType.Cookies: + return Cookies.fromJSON(item); case ObjectType.EmailAddress: return EmailAddress.fromJSON(item); case ObjectType.Email: diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 852c4546147..dd806587dff 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -10,6 +10,7 @@ export const ObjectType = { AccountlessApplication: 'accountless_application', AllowlistIdentifier: 'allowlist_identifier', Client: 'client', + Cookies: 'cookies', Email: 'email', EmailAddress: 'email_address', ExternalAccount: 'external_account', @@ -44,6 +45,11 @@ export interface ClerkResourceJSON { id: string; } +export interface CookiesJSON { + object: typeof ObjectType.Cookies; + cookies: string[]; +} + export interface TokenJSON { object: typeof ObjectType.Token; jwt: string; diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index 20854c56441..49f8daed724 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,6 +1,7 @@ export * from './AccountlessApplication'; export * from './AllowlistIdentifier'; export * from './Client'; +export * from './Cookies'; export * from './DeletedObject'; export * from './Email'; export * from './EmailAddress'; diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 76a00601bf7..d4a4042caf0 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -972,130 +972,138 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState.toAuth()).toBeSignedInToAuth(); }); - test('refreshToken: returns signed in with valid refresh token cookie if token is expired and refresh token exists', async () => { - server.use( - http.get('https://api.clerk.test/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), - ); - - // return cookies from endpoint - const refreshSession = vi.fn(() => ({ - object: 'token', - jwt: mockJwt, - })); - - const requestState = await authenticateRequest( - mockRequestWithCookies( - { - ...defaultHeaders, - origin: 'https://example.com', - }, - { __client_uat: `12345`, __session: mockExpiredJwt, __refresh_MqCvchyS: 'can_be_anything' }, - ), - mockOptions({ - secretKey: 'test_deadbeef', - publishableKey: PK_LIVE, - apiClient: { sessions: { refreshSession } }, - }), - ); - - expect(requestState).toBeSignedIn(); - expect(requestState.toAuth()).toBeSignedInToAuth(); - expect(refreshSession).toHaveBeenCalled(); - }); - - test('refreshToken: does not try to refresh if refresh token does not exist', async () => { - server.use( - http.get('https://api.clerk.test/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), - ); - - // return cookies from endpoint - const refreshSession = vi.fn(() => ({ - object: 'token', - jwt: mockJwt, - })); - - await authenticateRequest( - mockRequestWithCookies( - { - ...defaultHeaders, - origin: 'https://example.com', - }, - { __client_uat: `12345`, __session: mockExpiredJwt }, - ), - mockOptions({ - secretKey: 'test_deadbeef', - publishableKey: PK_LIVE, - apiClient: { sessions: { refreshSession } }, - }), - ); - expect(refreshSession).not.toHaveBeenCalled(); - }); - - test('refreshToken: does not try to refresh if refresh exists but token is not expired', async () => { - // return cookies from endpoint - const refreshSession = vi.fn(() => ({ - object: 'token', - jwt: mockJwt, - })); - - await authenticateRequest( - mockRequestWithCookies( - { - ...defaultHeaders, - origin: 'https://example.com', - }, - // client_uat is missing, need to handshake not to refresh - { __session: mockJwt, __refresh_MqCvchyS: 'can_be_anything' }, - ), - mockOptions({ - secretKey: 'test_deadbeef', - publishableKey: PK_LIVE, - apiClient: { sessions: { refreshSession } }, - }), - ); - - expect(refreshSession).not.toHaveBeenCalled(); - }); - - test('refreshToken: uses suffixed refresh cookie even if un-suffixed is present', async () => { - server.use( - http.get('https://api.clerk.test/v1/jwks', () => { - return HttpResponse.json(mockJwks); - }), - ); + describe('refreshToken', async () => { + test('returns signed in with valid refresh token cookie if token is expired and refresh token exists', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + // return cookies from endpoint + const refreshSession = vi.fn(() => ({ + object: 'cookies', + cookies: [`__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`], + })); + + const requestState = await authenticateRequest( + mockRequestWithCookies( + { + ...defaultHeaders, + origin: 'https://example.com', + }, + { __client_uat: `12345`, __session: mockExpiredJwt, __refresh_MqCvchyS: 'can_be_anything' }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + publishableKey: PK_LIVE, + apiClient: { sessions: { refreshSession } }, + }), + ); + + expect(requestState).toBeSignedIn(); + expect(requestState.toAuth()).toBeSignedInToAuth(); + expect(requestState.headers.getSetCookie()).toContain( + `__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`, + ); + expect(refreshSession).toHaveBeenCalled(); + }); - // return cookies from endpoint - const refreshSession = vi.fn(() => ({ - object: 'token', - jwt: mockJwt, - })); + test('does not try to refresh if refresh token does not exist', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + // return cookies from endpoint + const refreshSession = vi.fn(() => ({ + object: 'cookies', + cookies: [`__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`], + })); + + await authenticateRequest( + mockRequestWithCookies( + { + ...defaultHeaders, + origin: 'https://example.com', + }, + { __client_uat: `12345`, __session: mockExpiredJwt }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + publishableKey: PK_LIVE, + apiClient: { sessions: { refreshSession } }, + }), + ); + expect(refreshSession).not.toHaveBeenCalled(); + }); - const requestState = await authenticateRequest( - mockRequestWithCookies( - { - ...defaultHeaders, - origin: 'https://example.com', - }, - { - __client_uat: `12345`, - __session: mockExpiredJwt, - __refresh_MqCvchyS: 'can_be_anything', - __refresh: 'should_not_be_used', - }, - ), - mockOptions({ - secretKey: 'test_deadbeef', - publishableKey: PK_LIVE, - apiClient: { sessions: { refreshSession } }, - }), - ); + test('does not try to refresh if refresh exists but token is not expired', async () => { + // return cookies from endpoint + const refreshSession = vi.fn(() => ({ + object: 'cookies', + cookies: [`__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`], + })); + + await authenticateRequest( + mockRequestWithCookies( + { + ...defaultHeaders, + origin: 'https://example.com', + }, + // client_uat is missing, need to handshake not to refresh + { __session: mockJwt, __refresh_MqCvchyS: 'can_be_anything' }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + publishableKey: PK_LIVE, + apiClient: { sessions: { refreshSession } }, + }), + ); + + expect(refreshSession).not.toHaveBeenCalled(); + }); - expect(requestState).toBeSignedIn(); - expect(requestState.toAuth()).toBeSignedInToAuth(); - expect(refreshSession).toHaveBeenCalled(); + test('uses suffixed refresh cookie even if un-suffixed is present', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + // return cookies from endpoint + const refreshSession = vi.fn(() => ({ + object: 'cookies', + cookies: [`__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`], + })); + + const requestState = await authenticateRequest( + mockRequestWithCookies( + { + ...defaultHeaders, + origin: 'https://example.com', + }, + { + __client_uat: `12345`, + __session: mockExpiredJwt, + __refresh_MqCvchyS: 'can_be_anything', + __refresh: 'should_not_be_used', + }, + ), + mockOptions({ + secretKey: 'test_deadbeef', + publishableKey: PK_LIVE, + apiClient: { sessions: { refreshSession } }, + }), + ); + + expect(requestState).toBeSignedIn(); + expect(requestState.toAuth()).toBeSignedInToAuth(); + expect(requestState.headers.getSetCookie()).toContain( + `__session_MqCvchyS=${mockJwt}; Path=/; Secure; SameSite=Lax`, + ); + expect(refreshSession).toHaveBeenCalled(); + }); }); }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 46c2445cb73..1ea17f1eb5c 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -219,7 +219,7 @@ ${error.getFullMessage()}`, async function refreshToken( authenticateContext: AuthenticateContext, - ): Promise<{ data: string; error: null } | { data: null; error: any }> { + ): Promise<{ data: string[]; error: null } | { data: null; error: any }> { // To perform a token refresh, apiClient must be defined. if (!options.apiClient) { return { @@ -273,14 +273,16 @@ ${error.getFullMessage()}`, try { // Perform the actual token refresh. - const tokenResponse = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, { + const response = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, { + format: 'cookie', + suffixed_cookies: authenticateContext.usesSuffixedCookies(), expired_token: expiredSessionToken || '', refresh_token: refreshToken || '', request_origin: authenticateContext.clerkUrl.origin, // The refresh endpoint expects headers as Record, so we need to transform it. request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])), }); - return { data: tokenResponse.jwt, error: null }; + return { data: response.cookies, error: null }; } catch (err: any) { if (err?.errors?.length) { if (err.errors[0].code === 'unexpected_error') { @@ -313,12 +315,24 @@ ${error.getFullMessage()}`, async function attemptRefresh( authenticateContext: AuthenticateContext, - ): Promise<{ data: { jwtPayload: JwtPayload; sessionToken: string }; error: null } | { data: null; error: any }> { - const { data: sessionToken, error } = await refreshToken(authenticateContext); - if (!sessionToken) { + ): Promise< + | { data: { jwtPayload: JwtPayload; sessionToken: string; headers: Headers }; error: null } + | { data: null; error: any } + > { + const { data: cookiesToSet, error } = await refreshToken(authenticateContext); + if (!cookiesToSet || cookiesToSet.length === 0) { return { data: null, error }; } + const headers = new Headers(); + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (getCookieName(x).startsWith(constants.Cookies.Session)) { + sessionToken = getCookieValue(x); + } + }); + // Since we're going to return a signedIn response, we need to decode the data from the new sessionToken. const { data: jwtPayload, errors } = await verifyToken(sessionToken, authenticateContext); if (errors) { @@ -330,7 +344,7 @@ ${error.getFullMessage()}`, }, }; } - return { data: { jwtPayload, sessionToken }, error: null }; + return { data: { jwtPayload, sessionToken, headers }, error: null }; } function handleMaybeHandshakeStatus( @@ -631,7 +645,7 @@ ${error.getFullMessage()}`, if (isRequestEligibleForRefresh(err, authenticateContext, request)) { const { data, error } = await attemptRefresh(authenticateContext); if (data) { - return signedIn(authenticateContext, data.jwtPayload, undefined, data.sessionToken); + return signedIn(authenticateContext, data.jwtPayload, data.headers, data.sessionToken); } // If there's any error, simply fallback to the handshake flow including the reason as a query parameter.