Skip to content

Commit 3fee736

Browse files
committed
fix(backend): Requests to satellite apps visited by non browsers should be treated as signed out
This is necessary, because tools like `opengraph.xyz` will not follow redirects, and they will always end up with parsing our interstitial template.
1 parent 566f7af commit 3fee736

File tree

3 files changed

+79
-16
lines changed

3 files changed

+79
-16
lines changed

.changeset/proud-cycles-type.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Treat expired JWT as signed-out state for requests originated from non-browser clients on satellite apps

packages/backend/src/tokens/interstitialRule.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const hasJustSynced = (qp?: URLSearchParams) => qp?.get('__clerk_synced') === 't
1515
const isReturningFromPrimary = (qp?: URLSearchParams) => qp?.get('__clerk_referrer_primary') === 'true';
1616

1717
const VALID_USER_AGENTS = /^Mozilla\/|(Amazon CloudFront)/;
18+
19+
const isBrowser = (userAgent: string | undefined) => VALID_USER_AGENTS.test(userAgent || '');
20+
1821
// In development or staging environments only, based on the request's
1922
// User Agent, detect non-browser requests (e.g. scripts). Since there
2023
// is no Authorization header, consider the user as signed out and
@@ -24,7 +27,7 @@ const VALID_USER_AGENTS = /^Mozilla\/|(Amazon CloudFront)/;
2427
export const nonBrowserRequestInDevRule: InterstitialRule = options => {
2528
const { apiKey, secretKey, userAgent } = options;
2629
const key = secretKey || apiKey;
27-
if (isDevelopmentFromApiKey(key) && !VALID_USER_AGENTS.test(userAgent || '')) {
30+
if (isDevelopmentFromApiKey(key) && !isBrowser(userAgent)) {
2831
return signedOut(options, AuthErrorReason.HeaderMissingNonBrowser);
2932
}
3033
return undefined;
@@ -184,12 +187,18 @@ async function verifyRequestState(options: AuthenticateRequestOptions, token: st
184187
* Let the next rule for UatMissing to fire if needed
185188
*/
186189
export const isSatelliteAndNeedsSyncing: InterstitialRule = options => {
187-
const { clientUat, isSatellite, searchParams, secretKey, apiKey } = options;
190+
const { clientUat, isSatellite, searchParams, secretKey, apiKey, userAgent } = options;
188191

189192
const key = secretKey || apiKey;
190193
const isDev = isDevelopmentFromApiKey(key);
191194

192-
if (isSatellite && (!clientUat || clientUat === '0') && !hasJustSynced(searchParams) && !isDev) {
195+
const isSignedOut = !clientUat || clientUat === '0';
196+
197+
if (isSatellite && isSignedOut && !isBrowser(userAgent)) {
198+
return signedOut(options, AuthErrorReason.SatelliteCookieNeedsSyncing);
199+
}
200+
201+
if (isSatellite && isSignedOut && !hasJustSynced(searchParams) && !isDev) {
193202
return interstitial(options, AuthErrorReason.SatelliteCookieNeedsSyncing);
194203
}
195204

packages/backend/src/tokens/request.test.ts

+62-13
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ import sinon from 'sinon';
33

44
import runtime from '../runtime';
55
import { jsonOk } from '../util/mockFetch';
6-
import { type AuthReason, type RequestState, AuthErrorReason, AuthStatus } from './authStatus';
6+
import { AuthErrorReason, type AuthReason, AuthStatus, type RequestState } from './authStatus';
77
import { TokenVerificationErrorReason } from './errors';
88
import { mockInvalidSignatureJwt, mockJwks, mockJwt, mockJwtPayload, mockMalformedJwt } from './fixtures';
99
import type { AuthenticateRequestOptions } from './request';
1010
import { authenticateRequest } from './request';
1111

12-
function assertSignedOut(assert, requestState: RequestState, reason: AuthReason, message = '') {
12+
function assertSignedOut(
13+
assert,
14+
requestState: RequestState,
15+
expectedState: {
16+
reason: AuthReason;
17+
isSatellite?: boolean;
18+
domain?: string;
19+
signInUrl?: string;
20+
message?: string;
21+
},
22+
) {
1323
assert.propEqual(requestState, {
1424
frontendApi: 'cafe.babe.clerk.ts',
1525
publishableKey: '',
@@ -21,9 +31,9 @@ function assertSignedOut(assert, requestState: RequestState, reason: AuthReason,
2131
isSatellite: false,
2232
signInUrl: '',
2333
domain: '',
24-
message,
25-
reason,
34+
message: '',
2635
toAuth: {},
36+
...expectedState,
2737
});
2838
}
2939

@@ -174,7 +184,10 @@ export default (QUnit: QUnit) => {
174184

175185
const errMessage =
176186
'The JWKS endpoint did not contain any signing keys. Contact support@clerk.com. Contact support@clerk.com (reason=jwk-remote-failed-to-load, token-carrier=header)';
177-
assertSignedOut(assert, requestState, TokenVerificationErrorReason.RemoteJWKFailedToLoad, errMessage);
187+
assertSignedOut(assert, requestState, {
188+
reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad,
189+
message: errMessage,
190+
});
178191
assertSignedOutToAuth(assert, requestState);
179192
});
180193

@@ -204,7 +217,10 @@ export default (QUnit: QUnit) => {
204217

205218
const errMessage =
206219
'Invalid JWT Authorized party claim (azp) "https://accounts.inspired.puma-74.lcl.dev". Expected "whatever". (reason=token-invalid-authorized-parties, token-carrier=header)';
207-
assertSignedOut(assert, requestState, TokenVerificationErrorReason.TokenInvalidAuthorizedParties, errMessage);
220+
assertSignedOut(assert, requestState, {
221+
reason: TokenVerificationErrorReason.TokenInvalidAuthorizedParties,
222+
message: errMessage,
223+
});
208224
assertSignedOutToAuth(assert, requestState);
209225
});
210226

@@ -228,7 +244,10 @@ export default (QUnit: QUnit) => {
228244
});
229245

230246
const errMessage = 'JWT signature is invalid. (reason=token-invalid-signature, token-carrier=header)';
231-
assertSignedOut(assert, requestState, TokenVerificationErrorReason.TokenInvalidSignature, errMessage);
247+
assertSignedOut(assert, requestState, {
248+
reason: TokenVerificationErrorReason.TokenInvalidSignature,
249+
message: errMessage,
250+
});
232251
assertSignedOutToAuth(assert, requestState);
233252
});
234253

@@ -240,7 +259,10 @@ export default (QUnit: QUnit) => {
240259

241260
const errMessage =
242261
'Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, token-carrier=header)';
243-
assertSignedOut(assert, requestState, TokenVerificationErrorReason.TokenInvalid, errMessage);
262+
assertSignedOut(assert, requestState, {
263+
reason: TokenVerificationErrorReason.TokenInvalid,
264+
message: errMessage,
265+
});
244266
assertSignedOutToAuth(assert, requestState);
245267
});
246268

@@ -256,7 +278,9 @@ export default (QUnit: QUnit) => {
256278
cookieToken: mockJwt,
257279
});
258280

259-
assertSignedOut(assert, requestState, AuthErrorReason.HeaderMissingCORS);
281+
assertSignedOut(assert, requestState, {
282+
reason: AuthErrorReason.HeaderMissingCORS,
283+
});
260284
assertSignedOutToAuth(assert, requestState);
261285
});
262286

@@ -270,7 +294,7 @@ export default (QUnit: QUnit) => {
270294
cookieToken: mockJwt,
271295
});
272296

273-
assertSignedOut(assert, requestState, AuthErrorReason.HeaderMissingNonBrowser);
297+
assertSignedOut(assert, requestState, { reason: AuthErrorReason.HeaderMissingNonBrowser });
274298
assertSignedOutToAuth(assert, requestState);
275299
});
276300

@@ -292,6 +316,24 @@ export default (QUnit: QUnit) => {
292316
assert.strictEqual(requestState.toAuth(), null);
293317
});
294318

319+
test('cookieToken: returns signed out is satellite but a non-browser request [11y]', async assert => {
320+
const requestState = await authenticateRequest({
321+
...defaultMockAuthenticateRequestOptions,
322+
apiKey: 'deadbeef',
323+
clientUat: '0',
324+
isSatellite: true,
325+
domain: 'satellite.dev',
326+
userAgent: '[some-agent]',
327+
});
328+
329+
assertSignedOut(assert, requestState, {
330+
reason: AuthErrorReason.SatelliteCookieNeedsSyncing,
331+
isSatellite: true,
332+
domain: 'satellite.dev',
333+
});
334+
assertSignedOutToAuth(assert, requestState);
335+
});
336+
295337
test('returns interstitial when app is satellite, returns from primary and is dev instance [13y]', async assert => {
296338
const sp = new URLSearchParams();
297339
sp.set('__clerk_referrer_primary', 'true');
@@ -339,7 +381,9 @@ export default (QUnit: QUnit) => {
339381
apiKey: 'live_deadbeef',
340382
});
341383

342-
assertSignedOut(assert, requestState, AuthErrorReason.CookieAndUATMissing);
384+
assertSignedOut(assert, requestState, {
385+
reason: AuthErrorReason.CookieAndUATMissing,
386+
});
343387
assertSignedOutToAuth(assert, requestState);
344388
});
345389

@@ -430,7 +474,9 @@ export default (QUnit: QUnit) => {
430474
clientUat: '0',
431475
});
432476

433-
assertSignedOut(assert, requestState, AuthErrorReason.StandardSignedOut);
477+
assertSignedOut(assert, requestState, {
478+
reason: AuthErrorReason.StandardSignedOut,
479+
});
434480
assertSignedOutToAuth(assert, requestState);
435481
});
436482

@@ -455,7 +501,10 @@ export default (QUnit: QUnit) => {
455501

456502
const errMessage =
457503
'Subject claim (sub) is required and must be a string. Received undefined. Make sure that this is a valid Clerk generate JWT. (reason=token-verification-failed, token-carrier=cookie)';
458-
assertSignedOut(assert, requestState, TokenVerificationErrorReason.TokenVerificationFailed, errMessage);
504+
assertSignedOut(assert, requestState, {
505+
reason: TokenVerificationErrorReason.TokenVerificationFailed,
506+
message: errMessage,
507+
});
459508
assertSignedOutToAuth(assert, requestState);
460509
});
461510

0 commit comments

Comments
 (0)