Skip to content

Commit

Permalink
feat(backend): Add support for refresh token flow (#4154)
Browse files Browse the repository at this point in the history
Co-authored-by: Nikos Douvlis <[email protected]>
  • Loading branch information
dstaley and nikosdouvlis authored Sep 16, 2024
1 parent 2d8e6e9 commit e578b15
Show file tree
Hide file tree
Showing 12 changed files with 263 additions and 28 deletions.
5 changes: 5 additions & 0 deletions .changeset/wicked-buses-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/backend": minor
---

Add new refresh token flow to reduce errors arising from too many redirects
17 changes: 17 additions & 0 deletions packages/backend/src/api/endpoints/SessionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ type SessionListParams = ClerkPaginationRequest<{
status?: SessionStatus;
}>;

type RefreshTokenParams = {
expired_token: string;
refresh_token: string;
request_origin: string;
request_originating_ip?: string;
request_headers?: Record<string, string[]>;
};

export class SessionAPI extends AbstractAPI {
public async getSessionList(params: SessionListParams = {}) {
return this.request<PaginatedResourceResponse<Session[]>>({
Expand Down Expand Up @@ -55,4 +63,13 @@ export class SessionAPI extends AbstractAPI {
path: joinPaths(basePath, sessionId, 'tokens', template || ''),
});
}

public async refreshSession(sessionId: string, params: RefreshTokenParams) {
this.requireId(sessionId);
return this.request<Token>({
method: 'POST',
path: joinPaths(basePath, sessionId, 'refresh'),
bodyParams: params,
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const Attributes = {

const Cookies = {
Session: '__session',
Refresh: '__refresh',
ClientUat: '__client_uat',
Handshake: '__clerk_handshake',
DevBrowser: '__clerk_db_jwt',
Expand Down
9 changes: 7 additions & 2 deletions packages/backend/src/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { base64url } from '../util/rfc4648';

// signed with signingJwks
export const mockJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w';

// signed with signingJwks, same as mockJwt but with lower iat and exp values
export const mockExpiredJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODIwMCwiaWF0IjoxNjY2NjQ3MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.jLImjg2vGwOJDkK9gtIeJnEJWVOgoCMeC46OFtfzJ1d8OT0KVvwRppC60QIMfHKoOwLTLYlq8SccrkARwlJ_jOvMAYMGZT-R4qHoEfGmet1cSTC67zaafq5gpf9759x1kNMyckry_PJNSx-9hTFbBMWhY7XVLVlrauppqHXOQr1-BC7u-0InzKjCQTCJj-81Yt8xRKweLbO689oYSRAFYK5LNH8BYoLZFWuWLO-6nxUJu0_XAq9xpZPqZOqj3LxFS4hHVGGmTqnPgR8vBetLXxSLAOBsEyIkeQkOBA03YA6enTNIppmy0XTLgAYmUO_JWOGjjjDQoEojuXtuLRdQHQ';

export const mockInvalidSignatureJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLnRhbXBlcmVkLWRvbWFpbi5kZXYiLCJleHAiOjE2NjY2NDgzMTAsImlhdCI6MTY2NjY0ODI1MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5pbnNwaXJlZC5wdW1hLTc0LmxjbC5kZXYiLCJuYmYiOjE2NjY2NDgyNDAsInNpZCI6InNlc3NfMkdiREI0ZW5OZENhNXZTMXpwQzNYemc5dEs5Iiwic3ViIjoidXNlcl8yR0lwWE9FcFZ5Snc1MXJrWm45S21uYzZTeHIifQ.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg';
Expand Down Expand Up @@ -35,7 +40,7 @@ export const mockRsaJwk = {
kty: 'RSA',
kid: mockRsaJwkKid,
alg: 'RS256',
n: 'u0tNUitBZmcGYMWcqvaRBaJe0XmTQ738RHYoHjhYANyeOkysuu4L_Rqr-fmTXsbebrTp7_OewIqsJXImEWB_WQ3HN9lAkOMCCGDU1udsz_sl1Kwy5JZ7x8Nr4ghXJagQzEF0Ovsj7_TPsBJGkVJ-OiZsTXCe7EAmG5gNGGPBE5Gu14Rwb-eZ5r9RCAaPfhxR1yHYTAvCrku_6i2os7RLpT6UockKtX4QQSH2CMveNwqd6LdwhV8USZrczB2VYkAImngJC745-EWek1sVExYkqheGvC3J8O7D9H4JtaKD2zaq0rJzsIU0zb_wwax5-La-uRuPYvTXlO8B8IK4jjNMCQ',
n: 'qYy3MY8fOneEyzNDu9lp6iGXKkNUF-u_dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsIjy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK_oPUOf38pVh7WnekcSBQmU5fqA-EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nNot0n1NE-9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV-AG8tBNiZVil415dpBldmJ_j0wk7Ad4VFi9en3Z17oCKr-K-zuT7vKMKSb1548dk0vnmi0vj2QGXSo-61wM5yQWpk6sQ',
e: 'AQAB',
};

Expand Down
127 changes: 123 additions & 4 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import type QUnit from 'qunit';
import sinon from 'sinon';

import { TokenVerificationErrorReason } from '../../errors';
import { mockInvalidSignatureJwt, mockJwks, mockJwt, mockJwtPayload, mockMalformedJwt } from '../../fixtures';
import {
mockExpiredJwt,
mockInvalidSignatureJwt,
mockJwks,
mockJwt,
mockJwtPayload,
mockMalformedJwt,
} from '../../fixtures';
import runtime from '../../runtime';
import { jsonOk } from '../../util/testUtils';
import { AuthErrorReason, type AuthReason, AuthStatus, type RequestState } from '../authStatus';
Expand Down Expand Up @@ -128,8 +135,8 @@ export default (QUnit: QUnit) => {
};

/* An otherwise bare state on a request. */
const mockOptions = (options?) =>
({
const mockOptions = (options?) => {
return {
secretKey: 'deadbeef',
apiUrl: 'https://api.clerk.test',
apiVersion: 'v1',
Expand All @@ -143,7 +150,8 @@ export default (QUnit: QUnit) => {
afterSignUpUrl: '',
domain: '',
...options,
}) satisfies AuthenticateRequestOptions;
} satisfies AuthenticateRequestOptions;
};

const mockRequestWithHeaderAuth = (headers?, requestUrl?) => {
return mockRequest({ authorization: mockJwt, ...headers }, requestUrl);
Expand All @@ -165,6 +173,8 @@ export default (QUnit: QUnit) => {
fakeClock = sinon.useFakeTimers(new Date(mockJwtPayload.iat * 1000).getTime());
fakeFetch = sinon.stub(runtime, 'fetch');
fakeFetch.onCall(0).returns(jsonOk(mockJwks));
// the refresh token flow calls verify twice, so we need to support two calls
fakeFetch.onCall(1).returns(jsonOk(mockJwks));
});

hooks.afterEach(() => {
Expand Down Expand Up @@ -564,5 +574,114 @@ export default (QUnit: QUnit) => {
assertSignedIn(assert, requestState);
assertSignedInToAuth(assert, requestState);
});

test('refreshToken: returns signed in with valid refresh token cookie if token is expired and refresh token exists', async assert => {
// return cookies from endpoint
const refreshSession = sinon.fake.resolves({
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 } },
}),
);

assertSignedIn(assert, requestState);
assertSignedInToAuth(assert, requestState);
});

test('refreshToken: does not try to refresh if refresh token does not exist', async assert => {
// return cookies from endpoint
const refreshSession = sinon.fake.resolves({
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 } },
}),
);

assert.false(refreshSession.called);
});

test('refreshToken: does not try to refresh if refresh exists but token is not expired', async assert => {
// return cookies from endpoint
const refreshSession = sinon.fake.resolves({
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 } },
}),
);

assert.false(refreshSession.called);
});

test('refreshToken: uses suffixed refresh cookie even if un-suffixed is present', async assert => {
// return cookies from endpoint
const refreshSession = sinon.fake.resolves({
object: 'token',
jwt: mockJwt,
});

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 } },
}),
);

assertSignedIn(assert, requestState);
assertSignedInToAuth(assert, requestState);
assert.equal(refreshSession.getCall(0).args[1].refresh_token, 'can_be_anything');
});
});
};
4 changes: 3 additions & 1 deletion packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions {
accept: string | undefined;
// cookie-based values
sessionTokenInCookie: string | undefined;
refreshTokenInCookie: string | undefined;
clientUat: number;
suffixedCookies: boolean;
// handshake-related values
Expand Down Expand Up @@ -60,7 +61,7 @@ class AuthenticateContext {
// as part of getMultipleAppsCookie
this.initPublishableKeyValues(options);
this.initHeaderValues();
// initCookieValues should be used before initHandshakeValues because the it depends on suffixedCookies
// initCookieValues should be used before initHandshakeValues because it depends on suffixedCookies
this.initCookieValues();
this.initHandshakeValues();
Object.assign(this, options);
Expand Down Expand Up @@ -97,6 +98,7 @@ class AuthenticateContext {
// suffixedCookies needs to be set first because it's used in getMultipleAppsCookie
this.suffixedCookies = this.shouldUseSuffixed();
this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedCookie(constants.Cookies.Session);
this.refreshTokenInCookie = this.getSuffixedCookie(constants.Cookies.Refresh);
this.clientUat = Number.parseInt(this.getSuffixedOrUnSuffixedCookie(constants.Cookies.ClientUat) || '') || 0;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/tokens/clerkRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { constants } from '../constants';
import type { ClerkUrl } from './clerkUrl';
import { createClerkUrl } from './clerkUrl';

/**
* A class that extends the native Request class,
* adds cookies helpers and a normalised clerkUrl that is constructed by using the values found
* in req.headers so it is able to work reliably when the app is running behind a proxy server.
*/
class ClerkRequest extends Request {
readonly clerkUrl: ClerkUrl;
readonly cookies: Map<string, string>;
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/tokens/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type CreateAuthenticateRequestOptions = {
*/
export function createAuthenticateRequest(params: CreateAuthenticateRequestOptions) {
const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options);
const apiClient = params.apiClient;

const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => {
const { apiUrl, apiVersion } = buildTimeOptions;
Expand All @@ -55,6 +56,7 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio
// to avoid runtime options override them.
apiUrl,
apiVersion,
apiClient,
});
};

Expand Down
Loading

0 comments on commit e578b15

Please sign in to comment.