Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-trees-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/backend": patch
---

Introduce more refresh token error reasons.
30 changes: 15 additions & 15 deletions integration/tests/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-outdated&__clerk_refresh=no-cookie${devBrowserQuery}`,
)}&suffixed_cookies=false&__clerk_hs_reason=session-token-expired&__clerk_refresh=no-cookie${devBrowserQuery}`,
);
});

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

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

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

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

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

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

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

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

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

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

Expand All @@ -601,7 +601,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`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}`,
`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}`,
);
});

Expand All @@ -624,7 +624,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`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`,
`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`,
);
});

Expand All @@ -647,7 +647,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`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}`,
`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}`,
);
});

Expand All @@ -670,7 +670,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`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`,
`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`,
);
});

Expand Down
134 changes: 96 additions & 38 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { JwtPayload } from '@clerk/types';

import type { ApiClient } from '../api';
import { constants } from '../constants';
import type { TokenCarrier } from '../errors';
import { TokenVerificationError, TokenVerificationErrorReason } from '../errors';
Expand All @@ -17,6 +16,18 @@ import { verifyHandshakeToken } from './handshake';
import type { AuthenticateRequestOptions } from './types';
import { verifyToken } from './verify';

const RefreshTokenErrorReason = {
NoCookie: 'no-cookie',
NonEligible: 'non-eligible',
InvalidSessionToken: 'invalid-session-token',
MissingApiClient: 'missing-api-client',
MissingSessionToken: 'missing-session-token',
MissingRefreshToken: 'missing-refresh-token',
SessionTokenDecodeFailed: 'session-token-decode-failed',
FetchNetworkError: 'fetch-network-error',
UnexpectedRefreshError: 'unexpected-refresh-error',
} as const;

function assertSignInUrlExists(signInUrl: string | undefined, key: string): asserts signInUrl is string {
if (!signInUrl && isDevelopmentFromSecretKey(key)) {
throw new Error(`Missing signInUrl. Pass a signInUrl for dev instances if an app is satellite`);
Expand All @@ -42,12 +53,6 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) {
}
}

function assertApiClient(apiClient: ApiClient | undefined): asserts apiClient is ApiClient {
if (!apiClient) {
throw new Error(`Missing apiClient. An apiClient is needed to perform token refresh.`);
}
}

/**
* 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.
* This heuristic should give us a reliable enough signal for browsers that support `Sec-Fetch-Dest` and for those that don't.
Expand Down Expand Up @@ -195,42 +200,75 @@ ${error.getFullMessage()}`,
throw error;
}

async function refreshToken(authenticateContext: AuthenticateContext): Promise<string> {
async function refreshToken(
authenticateContext: AuthenticateContext,
): Promise<{ data: string; error: null } | { data: null; error: any }> {
// To perform a token refresh, apiClient must be defined.
assertApiClient(options.apiClient);
if (!options.apiClient) {
return {
data: null,
error: {
message: 'An apiClient is needed to perform token refresh.',
cause: { reason: RefreshTokenErrorReason.MissingApiClient },
},
};
}
const { sessionToken: expiredSessionToken, refreshTokenInCookie: refreshToken } = authenticateContext;
if (!expiredSessionToken || !refreshToken) {
throw new Error('Clerk: refreshTokenInCookie and sessionToken must be provided.');
if (!expiredSessionToken) {
return {
data: null,
error: {
message: 'Session token must be provided.',
cause: { reason: RefreshTokenErrorReason.MissingSessionToken },
},
};
}
if (!refreshToken) {
return {
data: null,
error: {
message: 'Refresh token must be provided.',
cause: { reason: RefreshTokenErrorReason.MissingRefreshToken },
},
};
}
// The token refresh endpoint requires a sessionId, so we decode that from the expired token.
const { data: decodeResult, errors: decodedErrors } = decodeJwt(expiredSessionToken);
if (!decodeResult || decodedErrors) {
throw new Error(`Clerk: unable to decode session token.`);
}
// Perform the actual token refresh.
const tokenResponse = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, {
expired_token: expiredSessionToken || '',
refresh_token: refreshToken || '',
request_origin: authenticateContext.clerkUrl.origin,
// The refresh endpoint expects headers as Record<string, string[]>, so we need to transform it.
request_headers: Object.fromEntries(Array.from(request.headers.entries()).map(([k, v]) => [k, [v]])),
});

return tokenResponse.jwt;
}
return {
data: null,
error: {
message: 'Unable to decode session token.',
cause: { reason: RefreshTokenErrorReason.SessionTokenDecodeFailed, errors: decodedErrors },
},
};
}

async function attemptRefresh(
authenticateContext: AuthenticateContext,
): Promise<{ data: { jwtPayload: JwtPayload; sessionToken: string }; error: null } | { data: null; error: any }> {
let sessionToken: string;
try {
sessionToken = await refreshToken(authenticateContext);
// Perform the actual token refresh.
const tokenResponse = await options.apiClient.sessions.refreshSession(decodeResult.payload.sid, {
expired_token: expiredSessionToken || '',
refresh_token: refreshToken || '',
request_origin: authenticateContext.clerkUrl.origin,
// The refresh endpoint expects headers as Record<string, string[]>, 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 };
} catch (err: any) {
if (err?.errors?.length) {
if (err.errors[0].code === 'unexpected_error') {
return {
data: null,
error: {
message: `Fetch unexpected error`,
cause: { reason: RefreshTokenErrorReason.FetchNetworkError, errors: err.errors },
},
};
}
return {
data: null,
error: {
message: `Clerk: unable to refresh session token.`,
message: err.errors[0].code,
cause: { reason: err.errors[0].code, errors: err.errors },
},
};
Expand All @@ -241,14 +279,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) {
return { data: null, error };
}

// 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) {
return {
data: null,
error: {
message: `Clerk: unable to verify refreshed session token.`,
cause: { reason: 'invalid-session-token', errors },
cause: { reason: RefreshTokenErrorReason.InvalidSessionToken, errors },
},
};
}
Expand All @@ -264,7 +312,11 @@ ${error.getFullMessage()}`,
): SignedInState | SignedOutState | HandshakeState {
if (isRequestEligibleForHandshake(authenticateContext)) {
// If a refresh error is not passed in, we default to 'no-cookie' or 'non-eligible'.
refreshError = refreshError || (authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie');
refreshError =
refreshError ||
(authenticateContext.refreshTokenInCookie
? RefreshTokenErrorReason.NonEligible
: RefreshTokenErrorReason.NoCookie);

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

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

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

let refreshError: string = authenticateContext.refreshTokenInCookie ? 'non-eligible' : 'no-cookie';
let refreshError: string = authenticateContext.refreshTokenInCookie
? RefreshTokenErrorReason.NonEligible
: RefreshTokenErrorReason.NoCookie;

if (isRequestEligibleForRefresh(err, authenticateContext, request)) {
const { data, error } = await attemptRefresh(authenticateContext);
if (!error) {
return signedIn(authenticateContext, data!.jwtPayload, undefined, data!.sessionToken);
if (data) {
return signedIn(authenticateContext, data.jwtPayload, undefined, data.sessionToken);
}

// If there's any error, simply fallback to the handshake flow.
console.error('Clerk: unable to refresh token:', error?.message || error);
if (error?.cause?.reason) {
refreshError = error.cause.reason;
} else {
refreshError = 'unexpected-refresh-error';
refreshError = RefreshTokenErrorReason.UnexpectedRefreshError;
}
}

Expand Down