Skip to content

Commit

Permalink
feat(backend): Set cookies from the refresh token flow (#4884)
Browse files Browse the repository at this point in the history
Co-authored-by: panteliselef <[email protected]>
  • Loading branch information
brkalow and panteliselef authored Jan 15, 2025
1 parent 4d31fc9 commit e9e8834
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 133 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-eggs-retire.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 11 additions & 3 deletions packages/backend/src/api/endpoints/SessionApi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +21,8 @@ type RefreshTokenParams = {
request_origin: string;
request_originating_ip?: string;
request_headers?: Record<string, string[]>;
suffixed_cookies?: boolean;
format?: 'token' | 'cookie';
};

export class SessionAPI extends AbstractAPI {
Expand Down Expand Up @@ -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<Token>;
public async refreshSession(sessionId: string, params: RefreshTokenParams & { format: 'cookie' }): Promise<Cookies>;
public async refreshSession(sessionId: string, params: RefreshTokenParams): Promise<Token>;
public async refreshSession(sessionId: string, params: RefreshTokenParams): Promise<Token | Cookies> {
this.requireId(sessionId);
return this.request<Token>({
const { suffixed_cookies, ...restParams } = params;
return this.request({
method: 'POST',
path: joinPaths(basePath, sessionId, 'refresh'),
bodyParams: params,
bodyParams: restParams,
queryParams: { suffixed_cookies },
});
}
}
9 changes: 9 additions & 0 deletions packages/backend/src/api/resources/Cookies.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AllowlistIdentifier,
Client,
Cookies,
DeletedObject,
Email,
EmailAddress,
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
252 changes: 130 additions & 122 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading

0 comments on commit e9e8834

Please sign in to comment.