Skip to content

Commit e0ca9dc

Browse files
authored
fix(backend): Add more error reasons for refresh token query param (clerk#4193)
1 parent 11ebd02 commit e0ca9dc

3 files changed

Lines changed: 116 additions & 53 deletions

File tree

.changeset/weak-trees-perform.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/backend": patch
3+
---
4+
5+
Introduce more refresh token error reasons.

integration/tests/handshake.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ test.describe('Client handshake @generic', () => {
162162
expect(res.headers.get('location')).toBe(
163163
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
164164
`${app.serverUrl}/`,
165-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
165+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
166166
);
167167
});
168168

@@ -185,7 +185,7 @@ test.describe('Client handshake @generic', () => {
185185
expect(res.headers.get('location')).toBe(
186186
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
187187
`${app.serverUrl}/`,
188-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
188+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
189189
);
190190
});
191191

@@ -209,7 +209,7 @@ test.describe('Client handshake @generic', () => {
209209
expect(res.headers.get('location')).toBe(
210210
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
211211
`${app.serverUrl}/`,
212-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
212+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
213213
);
214214
});
215215

@@ -232,7 +232,7 @@ test.describe('Client handshake @generic', () => {
232232
expect(res.headers.get('location')).toBe(
233233
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
234234
`${app.serverUrl}/`,
235-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
235+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-not-active-yet&__clerk_refresh=no-cookie${devBrowserQuery}`,
236236
);
237237
});
238238

@@ -256,7 +256,7 @@ test.describe('Client handshake @generic', () => {
256256
expect(res.headers.get('location')).toBe(
257257
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
258258
`${app.serverUrl}/`,
259-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
259+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-not-active-yet&__clerk_refresh=no-cookie${devBrowserQuery}`,
260260
);
261261
});
262262

@@ -280,7 +280,7 @@ test.describe('Client handshake @generic', () => {
280280
expect(res.headers.get('location')).toBe(
281281
`https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(
282282
`${app.serverUrl}/`,
283-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
283+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
284284
);
285285
});
286286

@@ -304,7 +304,7 @@ test.describe('Client handshake @generic', () => {
304304
expect(res.headers.get('location')).toBe(
305305
`https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(
306306
`${app.serverUrl}/`,
307-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
307+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
308308
);
309309
});
310310

@@ -328,7 +328,7 @@ test.describe('Client handshake @generic', () => {
328328
expect(res.headers.get('location')).toBe(
329329
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
330330
`${app.serverUrl}/`,
331-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
331+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
332332
);
333333
});
334334

@@ -352,7 +352,7 @@ test.describe('Client handshake @generic', () => {
352352
expect(res.headers.get('location')).toBe(
353353
`https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(
354354
`${app.serverUrl}/`,
355-
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
355+
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
356356
);
357357
});
358358

@@ -555,7 +555,7 @@ test.describe('Client handshake @generic', () => {
555555
expect(res.headers.get('location')).toBe(
556556
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
557557
`${app.serverUrl}/`,
558-
)}hello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
558+
)}hello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
559559
);
560560
});
561561

@@ -578,7 +578,7 @@ test.describe('Client handshake @generic', () => {
578578
expect(res.headers.get('location')).toBe(
579579
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
580580
`${app.serverUrl}/`,
581-
)}hello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
581+
)}hello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
582582
);
583583
});
584584

@@ -601,7 +601,7 @@ test.describe('Client handshake @generic', () => {
601601
});
602602
expect(res.status).toBe(307);
603603
expect(res.headers.get('location')).toBe(
604-
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
604+
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
605605
);
606606
});
607607

@@ -624,7 +624,7 @@ test.describe('Client handshake @generic', () => {
624624
});
625625
expect(res.status).toBe(307);
626626
expect(res.headers.get('location')).toBe(
627-
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
627+
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
628628
);
629629
});
630630

@@ -647,7 +647,7 @@ test.describe('Client handshake @generic', () => {
647647
});
648648
expect(res.status).toBe(307);
649649
expect(res.headers.get('location')).toBe(
650-
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
650+
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
651651
);
652652
});
653653

@@ -670,7 +670,7 @@ test.describe('Client handshake @generic', () => {
670670
});
671671
expect(res.status).toBe(307);
672672
expect(res.headers.get('location')).toBe(
673-
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie`,
673+
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie`,
674674
);
675675
});
676676

packages/backend/src/tokens/request.ts

Lines changed: 96 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { JwtPayload } from '@clerk/types';
22

3-
import type { ApiClient } from '../api';
43
import { constants } from '../constants';
54
import type { TokenCarrier } from '../errors';
65
import { TokenVerificationError, TokenVerificationErrorReason } from '../errors';
@@ -17,6 +16,18 @@ import { verifyHandshakeToken } from './handshake';
1716
import type { AuthenticateRequestOptions } from './types';
1817
import { verifyToken } from './verify';
1918

19+
const RefreshTokenErrorReason = {
20+
NoCookie: 'no-cookie',
21+
NonEligible: 'non-eligible',
22+
InvalidSessionToken: 'invalid-session-token',
23+
MissingApiClient: 'missing-api-client',
24+
MissingSessionToken: 'missing-session-token',
25+
MissingRefreshToken: 'missing-refresh-token',
26+
SessionTokenDecodeFailed: 'session-token-decode-failed',
27+
FetchNetworkError: 'fetch-network-error',
28+
UnexpectedRefreshError: 'unexpected-refresh-error',
29+
} as const;
30+
2031
function assertSignInUrlExists(signInUrl: string | undefined, key: string): asserts signInUrl is string {
2132
if (!signInUrl && isDevelopmentFromSecretKey(key)) {
2233
throw new Error(`Missing signInUrl. Pass a signInUrl for dev instances if an app is satellite`);
@@ -42,12 +53,6 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) {
4253
}
4354
}
4455

45-
function assertApiClient(apiClient: ApiClient | undefined): asserts apiClient is ApiClient {
46-
if (!apiClient) {
47-
throw new Error(`Missing apiClient. An apiClient is needed to perform token refresh.`);
48-
}
49-
}
50-
5156
/**
5257
* Currently, a request is only eligible for a handshake if we can say it's *probably* a request for a document, not a fetch or some other exotic request.
5358
* This heuristic should give us a reliable enough signal for browsers that support `Sec-Fetch-Dest` and for those that don't.
@@ -195,42 +200,75 @@ ${error.getFullMessage()}`,
195200
throw error;
196201
}
197202

198-
async function refreshToken(authenticateContext: AuthenticateContext): Promise<string> {
203+
async function refreshToken(
204+
authenticateContext: AuthenticateContext,
205+
): Promise<{ data: string; error: null } | { data: null; error: any }> {
199206
// To perform a token refresh, apiClient must be defined.
200-
assertApiClient(options.apiClient);
207+
if (!options.apiClient) {
208+
return {
209+
data: null,
210+
error: {
211+
message: 'An apiClient is needed to perform token refresh.',
212+
cause: { reason: RefreshTokenErrorReason.MissingApiClient },
213+
},
214+
};
215+
}
201216
const { sessionToken: expiredSessionToken, refreshTokenInCookie: refreshToken } = authenticateContext;
202-
if (!expiredSessionToken || !refreshToken) {
203-
throw new Error('Clerk: refreshTokenInCookie and sessionToken must be provided.');
217+
if (!expiredSessionToken) {
218+
return {
219+
data: null,
220+
error: {
221+
message: 'Session token must be provided.',
222+
cause: { reason: RefreshTokenErrorReason.MissingSessionToken },
223+
},
224+
};
225+
}
226+
if (!refreshToken) {
227+
return {
228+
data: null,
229+
error: {
230+
message: 'Refresh token must be provided.',
231+
cause: { reason: RefreshTokenErrorReason.MissingRefreshToken },
232+
},
233+
};
204234
}
205235
// The token refresh endpoint requires a sessionId, so we decode that from the expired token.
206236
const { data: decodeResult, errors: decodedErrors } = decodeJwt(expiredSessionToken);
207237
if (!decodeResult || decodedErrors) {
208-
throw new Error(`Clerk: unable to decode session token.`);
209-
}
210-
// Perform the actual token refresh.
211-
const tokenResponse = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, {
212-
expired_token: expiredSessionToken || '',
213-
refresh_token: refreshToken || '',
214-
request_origin: authenticateContext.clerkUrl.origin,
215-
// The refresh endpoint expects headers as Record<string, string[]>, so we need to transform it.
216-
request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])),
217-
});
218-
219-
return tokenResponse.jwt;
220-
}
238+
return {
239+
data: null,
240+
error: {
241+
message: 'Unable to decode session token.',
242+
cause: { reason: RefreshTokenErrorReason.SessionTokenDecodeFailed, errors: decodedErrors },
243+
},
244+
};
245+
}
221246

222-
async function attemptRefresh(
223-
authenticateContext: AuthenticateContext,
224-
): Promise<{ data: { jwtPayload: JwtPayload; sessionToken: string }; error: null } | { data: null; error: any }> {
225-
let sessionToken: string;
226247
try {
227-
sessionToken = await refreshToken(authenticateContext);
248+
// Perform the actual token refresh.
249+
const tokenResponse = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, {
250+
expired_token: expiredSessionToken || '',
251+
refresh_token: refreshToken || '',
252+
request_origin: authenticateContext.clerkUrl.origin,
253+
// The refresh endpoint expects headers as Record<string, string[]>, so we need to transform it.
254+
request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])),
255+
});
256+
return { data: tokenResponse.jwt, error: null };
228257
} catch (err: any) {
229258
if (err?.errors?.length) {
259+
if (err.errors[0].code === 'unexpected_error') {
260+
return {
261+
data: null,
262+
error: {
263+
message: `Fetch unexpected error`,
264+
cause: { reason: RefreshTokenErrorReason.FetchNetworkError, errors: err.errors },
265+
},
266+
};
267+
}
230268
return {
231269
data: null,
232270
error: {
233-
message: `Clerk: unable to refresh session token.`,
271+
message: err.errors[0].code,
234272
cause: { reason: err.errors[0].code, errors: err.errors },
235273
},
236274
};
@@ -241,14 +279,24 @@ ${error.getFullMessage()}`,
241279
};
242280
}
243281
}
282+
}
283+
284+
async function attemptRefresh(
285+
authenticateContext: AuthenticateContext,
286+
): Promise<{ data: { jwtPayload: JwtPayload; sessionToken: string }; error: null } | { data: null; error: any }> {
287+
const { data: sessionToken, error } = await refreshToken(authenticateContext);
288+
if (!sessionToken) {
289+
return { data: null, error };
290+
}
291+
244292
// Since we're going to return a signedIn response, we need to decode the data from the new sessionToken.
245293
const { data: jwtPayload, errors } = await verifyToken(sessionToken, authenticateContext);
246294
if (errors) {
247295
return {
248296
data: null,
249297
error: {
250298
message: `Clerk: unable to verify refreshed session token.`,
251-
cause: { reason: 'invalid-session-token', errors },
299+
cause: { reason: RefreshTokenErrorReason.InvalidSessionToken, errors },
252300
},
253301
};
254302
}
@@ -264,7 +312,11 @@ ${error.getFullMessage()}`,
264312
): SignedInState | SignedOutState | HandshakeState {
265313
if (isRequestEligibleForHandshake(authenticateContext)) {
266314
// If a refresh error is not passed in, we default to 'no-cookie' or 'non-eligible'.
267-
refreshError = refreshError || (authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie');
315+
refreshError =
316+
refreshError ||
317+
(authenticateContext.refreshTokenInCookie
318+
? RefreshTokenErrorReason.NonEligible
319+
: RefreshTokenErrorReason.NoCookie);
268320

269321
// Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else.
270322
// In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic.
@@ -399,7 +451,9 @@ ${error.getFullMessage()}`,
399451
);
400452
const authErrReason = AuthErrorReason.SatelliteCookieNeedsSyncing;
401453
redirectURL.searchParams.append(constants.QueryParameters.HandshakeReason, authErrReason);
402-
const refreshTokenError = authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie';
454+
const refreshTokenError = authenticateContext.refreshTokenInCookie
455+
? RefreshTokenErrorReason.NonEligible
456+
: RefreshTokenErrorReason.NoCookie;
403457
redirectURL.searchParams.append(constants.QueryParameters.RefreshTokenError, refreshTokenError);
404458

405459
const headers = new Headers({ [constants.Headers.Location]: redirectURL.toString() });
@@ -423,7 +477,9 @@ ${error.getFullMessage()}`,
423477
redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.ClerkSynced, 'true');
424478
const authErrReason = AuthErrorReason.PrimaryRespondsToSyncing;
425479
redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.HandshakeReason, authErrReason);
426-
const refreshTokenError = authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie';
480+
const refreshTokenError = authenticateContext.refreshTokenInCookie
481+
? RefreshTokenErrorReason.NonEligible
482+
: RefreshTokenErrorReason.NoCookie;
427483
redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.RefreshTokenError, refreshTokenError);
428484

429485
const headers = new Headers({ [constants.Headers.Location]: redirectBackToSatelliteUrl.toString() });
@@ -481,20 +537,22 @@ ${error.getFullMessage()}`,
481537
return signedOut(authenticateContext, AuthErrorReason.UnexpectedError);
482538
}
483539

484-
let refreshError: string = authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie';
540+
let refreshError: string = authenticateContext.refreshTokenInCookie
541+
? RefreshTokenErrorReason.NonEligible
542+
: RefreshTokenErrorReason.NoCookie;
485543

486544
if (isRequestEligibleForRefresh(err, authenticateContext, request)) {
487545
const { data, error } = await attemptRefresh(authenticateContext);
488-
if (!error) {
489-
return signedIn(authenticateContext, data!.jwtPayload, undefined, data!.sessionToken);
546+
if (data) {
547+
return signedIn(authenticateContext, data.jwtPayload, undefined, data.sessionToken);
490548
}
491549

492550
// If there's any error, simply fallback to the handshake flow.
493551
console.error('Clerk: unable to refresh token:', error?.message || error);
494552
if (error?.cause?.reason) {
495553
refreshError = error.cause.reason;
496554
} else {
497-
refreshError = 'unexpected-refresh-error';
555+
refreshError = RefreshTokenErrorReason.UnexpectedRefreshError;
498556
}
499557
}
500558

0 commit comments

Comments
 (0)