Skip to content

Commit

Permalink
fix(backend): Add more error reasons for refresh token query param (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
anagstef committed Sep 23, 2024
1 parent 11ebd02 commit e0ca9dc
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 53 deletions.
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

0 comments on commit e0ca9dc

Please sign in to comment.