From 23046d5b66f2e9c786f14c83f9bc2b9db7c93bc5 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Thu, 30 Nov 2023 14:12:18 +0200 Subject: [PATCH 01/19] feat(backend): Try the new Client Handshake mechanism --- packages/backend/src/tokens/authStatus.ts | 84 +++++++-- packages/backend/src/tokens/request.ts | 201 ++++++++++++++++++---- 2 files changed, 241 insertions(+), 44 deletions(-) diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 401113e61be..19eafd62f3e 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -9,6 +9,7 @@ export enum AuthStatus { SignedIn = 'signed-in', SignedOut = 'signed-out', Interstitial = 'interstitial', + Handshake = 'handshake', Unknown = 'unknown', } @@ -28,6 +29,7 @@ export type SignedInState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedInAuthObject; + headers: Headers | null; }; export type SignedOutState = { @@ -46,6 +48,7 @@ export type SignedOutState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedOutAuthObject; + headers: Headers | null; }; export type InterstitialState = Omit & { @@ -54,6 +57,13 @@ export type InterstitialState = Omit null; }; +export type HandshakeState = Omit & { + status: AuthStatus.Handshake; + headers: Headers; + isInterstitial: false; + toAuth: () => null; +}; + export type UnknownState = Omit & { status: AuthStatus.Unknown; isInterstitial: false; @@ -61,6 +71,15 @@ export type UnknownState = Omit( options: T, sessionClaims: JwtPayload, + headers: Headers | null = null, ): Promise { const { publishableKey = '', @@ -128,8 +150,8 @@ export async function signedIn( secretKey, apiUrl, apiVersion, - cookieToken, - headerToken, + sessionTokenInCookie, + sessionTokenInHeader, loadSession, loadUser, loadOrganization, @@ -159,7 +181,7 @@ export async function signedIn( secretKey, apiUrl, apiVersion, - token: cookieToken || headerToken || '', + token: sessionTokenInCookie || sessionTokenInHeader || '', session, user, organization, @@ -183,12 +205,14 @@ export async function signedIn( isInterstitial: false, isUnknown: false, toAuth: () => authObject, + headers, }; } export function signedOut( options: T, reason: AuthReason, message = '', + headers: Headers | null = null, ): SignedOutState { const { publishableKey = '', @@ -216,6 +240,7 @@ export function signedOut( isSignedIn: false, isInterstitial: false, isUnknown: false, + headers, toAuth: () => signedOutAuthObject({ ...options, status: AuthStatus.SignedOut, reason, message }), }; } @@ -252,6 +277,44 @@ export function interstitial( isInterstitial: true, isUnknown: false, toAuth: () => null, + headers: new Headers(), + }; +} + +export function handshake( + options: T, + reason: AuthReason, + message = '', + headers: Headers, +): HandshakeState { + const { + publishableKey = '', + proxyUrl = '', + isSatellite = false, + domain = '', + signInUrl = '', + signUpUrl = '', + afterSignInUrl = '', + afterSignUpUrl = '', + } = options; + + return { + status: AuthStatus.Handshake, + reason, + message, + publishableKey, + isSatellite, + domain, + proxyUrl, + signInUrl, + signUpUrl, + afterSignInUrl, + afterSignUpUrl, + isSignedIn: false, + isUnknown: false, + headers, + isInterstitial: false, + toAuth: () => null, }; } @@ -283,5 +346,6 @@ export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, isInterstitial: false, isUnknown: true, toAuth: () => null, + headers: new Headers(), }; } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index bb61419175d..fdf125afdad 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,27 +1,18 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; + import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, interstitial, signedOut, unknownState } from './authStatus'; +import { AuthErrorReason, handshake, interstitial, signedIn, signedOut, unknownState } from './authStatus'; import type { TokenCarrier } from './errors'; import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; import type { InterstitialRuleOptions } from './interstitialRule'; -import { - crossOriginRequestWithoutHeader, - hasPositiveClientUatButCookieIsMissing, - hasValidCookieToken, - hasValidHeaderToken, - isNormalSignedOutState, - isPrimaryInDevAndRedirectsToSatellite, - isSatelliteAndNeedsSyncing, - nonBrowserRequestInDevRule, - potentialFirstLoadInDevWhenUATMissing, - potentialFirstRequestOnProductionEnvironment, - potentialRequestAfterSignInOrOutFromClerkHostedUiInDev, - runInterstitialRules, -} from './interstitialRule'; -import type { VerifyTokenOptions } from './verify'; +// TODO: Rename this crap, it's not interstitial anymore. +import { hasValidHeaderToken, runInterstitialRules } from './interstitialRule'; +import { decodeJwt } from './jwt'; +import { verifyToken, type VerifyTokenOptions } from './verify'; export type OptionalVerifyTokenOptions = Partial< Pick< VerifyTokenOptions, @@ -86,24 +77,166 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } async function authenticateRequestWithTokenInCookie() { - try { - const state = await runInterstitialRules(ruleOptions, [ - crossOriginRequestWithoutHeader, - nonBrowserRequestInDevRule, - isSatelliteAndNeedsSyncing, - isPrimaryInDevAndRedirectsToSatellite, - potentialFirstRequestOnProductionEnvironment, - potentialFirstLoadInDevWhenUATMissing, - potentialRequestAfterSignInOrOutFromClerkHostedUiInDev, - hasPositiveClientUatButCookieIsMissing, - isNormalSignedOutState, - hasValidCookieToken, - ]); + function buildRedirectToHandshake({ + publishableKey, + devBrowserToken, + redirectUrl, + }: { + publishableKey: string; + devBrowserToken: string; + redirectUrl: string; + }): string { + const pk = parsePublishableKey(publishableKey); - return state; + const url = new URL(`https://${pk?.frontendApi}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl); + + if (pk?.instanceType === 'development' && devBrowserToken) { + url.searchParams.append('__clerk_db_jwt', devBrowserToken); + } + return url.href; + } + + async function verifyRequestState(options: InterstitialRuleOptions, token: string) { + const { isSatellite, proxyUrl } = options; + let issuer; + if (isSatellite) { + issuer = null; + } else if (proxyUrl) { + issuer = proxyUrl; + } else { + issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); + } + + return verifyToken(token, { ...options, issuer }); + } + + const clientUat = parseInt(ruleOptions.clientUat || '', 10) || 0; + const hasActiveClient = clientUat > 0; + // TODO rename this to sessionToken + const sessionToken = ruleOptions.sessionTokenInCookie; + const hasSessionToken = !!sessionToken; + const handshakeToken = ruleOptions.handshakeToken; + + // ================ This is to end the handshake if necessary =================== + if (handshakeToken) { + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; + + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (x.startsWith('__session=')) { + sessionToken = x.split(';')[0].substring(10); + } + }); + + if (sessionToken === '') { + return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); + } + + try { + const verifyResult = await verifyRequestState(ruleOptions, sessionToken); + if (verifyResult) { + return signedIn(ruleOptions, verifyResult, headers); + } + } catch (err) { + return signedOut(ruleOptions, AuthErrorReason.ClockSkew, '', headers); + } + } + + // ================ This is to start the handshake if necessary =================== + if (!hasActiveClient && !hasSessionToken) { + return signedOut(ruleOptions, AuthErrorReason.CookieAndUATMissing); + } + + // This can eagerly run handshake since client_uat is SameSite=Strict in dev + if (!hasActiveClient && hasSessionToken) { + const headers = new Headers(); + headers.set( + 'Location', + buildRedirectToHandshake({ + publishableKey: ruleOptions.publishableKey!, + devBrowserToken: ruleOptions.devBrowserToken!, + redirectUrl: ruleOptions.request.url.toString(), + }), + ); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); + } + + if (hasActiveClient && !hasSessionToken) { + const headers = new Headers(); + headers.set( + 'Location', + buildRedirectToHandshake({ + publishableKey: ruleOptions.publishableKey!, + devBrowserToken: ruleOptions.devBrowserToken!, + redirectUrl: ruleOptions.request.url.toString(), + }), + ); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); + } + + const decodeResult = decodeJwt(sessionToken!); + + if (decodeResult.payload.iat < clientUat) { + const headers = new Headers(); + headers.set( + 'Location', + buildRedirectToHandshake({ + publishableKey: ruleOptions.publishableKey!, + devBrowserToken: ruleOptions.devBrowserToken!, + redirectUrl: ruleOptions.request.url.toString(), + }), + ); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + } + + try { + const verifyResult = await verifyRequestState(ruleOptions, sessionToken!); + if (verifyResult) { + return signedIn(ruleOptions, verifyResult); + } } catch (err) { - return handleError(err, 'cookie'); + if (err instanceof TokenVerificationError) { + err.tokenCarrier === 'cookie'; + + const reasonToHandshake = [ + TokenVerificationErrorReason.TokenExpired, + TokenVerificationErrorReason.TokenNotActiveYet, + ].includes(err.reason); + + if (reasonToHandshake) { + const headers = new Headers(); + headers.set( + 'Location', + buildRedirectToHandshake({ + publishableKey: ruleOptions.publishableKey!, + devBrowserToken: ruleOptions.devBrowserToken!, + redirectUrl: ruleOptions.request.url.toString(), + }), + ); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + } + return signedOut(ruleOptions, err.reason, err.getFullMessage()); + } + + return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); } + + return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); } function handleError(err: unknown, tokenCarrier: TokenCarrier) { @@ -126,7 +259,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): return signedOut(ruleOptions, AuthErrorReason.UnexpectedError, (err as Error).message); } - if (ruleOptions.headerToken) { + if (ruleOptions.sessionTokenInHeader) { return authenticateRequestWithTokenInHeader(); } return authenticateRequestWithTokenInCookie(); @@ -148,7 +281,7 @@ export const loadOptionsFromHeaders = (headers: ReturnType[ } return { - headerToken: stripAuthorizationHeader(headers(constants.Headers.Authorization)), + sessionTokenInHeader: stripAuthorizationHeader(headers(constants.Headers.Authorization)), origin: headers(constants.Headers.Origin), host: headers(constants.Headers.Host), forwardedHost: headers(constants.Headers.ForwardedHost), @@ -167,7 +300,7 @@ export const loadOptionsFromCookies = (cookies: ReturnType[ } return { - cookieToken: cookies?.(constants.Cookies.Session), + sessionTokenInCookie: cookies?.(constants.Cookies.Session), clientUat: cookies?.(constants.Cookies.ClientUat), }; }; From 88f877a8134b7f63e602d95fe04032c579a3f75c Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 30 Nov 2023 23:45:11 +0200 Subject: [PATCH 02/19] feat(backend): Update authenticateRequest handler to support multi-domain handshake --- packages/backend/src/tokens/authStatus.ts | 8 +- packages/backend/src/tokens/request.ts | 38 +++++- .../clerk-js/src/core/devBrowserHandler.ts | 7 +- packages/nextjs/src/server/authMiddleware.ts | 126 ++++++++---------- .../nextjs/src/server/authenticateRequest.ts | 6 +- packages/nextjs/src/server/utils.ts | 6 +- packages/shared/src/devBrowser.ts | 2 +- 7 files changed, 103 insertions(+), 90 deletions(-) diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 19eafd62f3e..e19d094e6e5 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -29,7 +29,7 @@ export type SignedInState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedInAuthObject; - headers: Headers | null; + headers: Headers; }; export type SignedOutState = { @@ -48,7 +48,7 @@ export type SignedOutState = { isInterstitial: false; isUnknown: false; toAuth: () => SignedOutAuthObject; - headers: Headers | null; + headers: Headers; }; export type InterstitialState = Omit & { @@ -136,7 +136,7 @@ export type AuthStatusOptionsType = LoadResourcesOptions & export async function signedIn( options: T, sessionClaims: JwtPayload, - headers: Headers | null = null, + headers: Headers = new Headers(), ): Promise { const { publishableKey = '', @@ -212,7 +212,7 @@ export function signedOut( options: T, reason: AuthReason, message = '', - headers: Headers | null = null, + headers: Headers = new Headers(), ): SignedOutState { const { publishableKey = '', diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index fdf125afdad..81b7fdb4fb4 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -3,7 +3,8 @@ import { parsePublishableKey } from '@clerk/shared/keys'; import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; -import { isDevelopmentFromSecretKey } from '../util/shared'; +import { addClerkPrefix, isDevelopmentFromSecretKey, isDevOrStagingUrl } from '../util/shared'; +import { buildRequestUrl } from '../utils'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; import { AuthErrorReason, handshake, interstitial, signedIn, signedOut, unknownState } from './authStatus'; import type { TokenCarrier } from './errors'; @@ -81,14 +82,21 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): publishableKey, devBrowserToken, redirectUrl, + domain, + proxyUrl, }: { publishableKey: string; devBrowserToken: string; redirectUrl: string; + domain?: string; + proxyUrl?: string; }): string { const pk = parsePublishableKey(publishableKey); + const pkFapi = pk?.frontendApi || ''; + // determine proper FAPI url, taking into account multi-domain setups + const frontendApi = proxyUrl || (!isDevOrStagingUrl(pkFapi) ? addClerkPrefix(domain) : '') || pkFapi; - const url = new URL(`https://${pk?.frontendApi}/v1/client/handshake`); + const url = new URL(`https://${frontendApi}/v1/client/handshake`); url.searchParams.append('redirect_url', redirectUrl); if (pk?.instanceType === 'development' && devBrowserToken) { @@ -135,6 +143,14 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } }); + if (parsePublishableKey(options.publishableKey)?.instanceType === 'development') { + const newUrl = new URL(options.request.url); + newUrl.searchParams.delete('__clerk_handshake'); + newUrl.searchParams.delete('__clerk_help'); + + headers.append('Location', newUrl.toString()); + } + if (sessionToken === '') { return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); } @@ -150,6 +166,24 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } // ================ This is to start the handshake if necessary =================== + if (ruleOptions.isSatellite && !new URL(options.request.url).searchParams.has('__clerk_synced')) { + const redirectUrl = buildRequestUrl(options.request); + const headers = new Headers(); + headers.set( + 'Location', + buildRedirectToHandshake({ + publishableKey: ruleOptions.publishableKey!, + devBrowserToken: ruleOptions.devBrowserToken!, + redirectUrl: redirectUrl.toString(), + proxyUrl: ruleOptions.proxyUrl, + domain: ruleOptions.domain, + }), + ); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + if (!hasActiveClient && !hasSessionToken) { return signedOut(ruleOptions, AuthErrorReason.CookieAndUATMissing); } diff --git a/packages/clerk-js/src/core/devBrowserHandler.ts b/packages/clerk-js/src/core/devBrowserHandler.ts index 90851a2575a..95216066cb1 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.ts @@ -14,7 +14,7 @@ export interface DevBrowserHandler { setup(): Promise; - getDevBrowserJWT(): string | null; + getDevBrowserJWT(): string | undefined; setDevBrowserJWT(jwt: string): void; @@ -39,11 +39,10 @@ export function createDevBrowserHandler({ let usesUrlBasedSessionSyncing = true; function getDevBrowserJWT() { - return localStorage.getItem(key); + return cookieHandler.getDevBrowserCookie(); } function setDevBrowserJWT(jwt: string) { - localStorage.setItem(key, jwt); // Append dev browser JWT to cookies, because server-side redirects (e.g. middleware) has no access to local storage cookieHandler.setDevBrowserCookie(jwt); } @@ -116,7 +115,7 @@ export function createDevBrowserHandler({ } // 2. If no JWT is found in the first step, check if a JWT is already available in the local cache - if (getDevBrowserJWT() !== null) { + if (getDevBrowserJWT()) { return; } diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 53ce94693da..a575c0f72e0 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -1,5 +1,5 @@ -import type { AuthObject, RequestState } from '@clerk/backend'; -import { buildRequestUrl, constants, TokenVerificationErrorReason } from '@clerk/backend'; +import type { AuthenticateRequestOptions, AuthObject } from '@clerk/backend'; +import { AuthStatus, buildRequestUrl, constants } from '@clerk/backend'; import { DEV_BROWSER_JWT_MARKER, setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { eventMethodCalled } from '@clerk/shared/telemetry'; @@ -10,15 +10,10 @@ import { NextResponse } from 'next/server'; import { isRedirect, mergeResponses, paths, setHeader, stringifyHeaders } from '../utils'; import { withLogger } from '../utils/debugLogger'; -import { authenticateRequest, handleInterstitialState, handleUnknownState } from './authenticateRequest'; +import { authenticateRequest } from './authenticateRequest'; import { clerkClient } from './clerkClient'; import { SECRET_KEY } from './constants'; -import { - clockSkewDetected, - infiniteRedirectLoopDetected, - informAboutProtectedRouteInfo, - receivedRequestForIgnoredRoute, -} from './errors'; +import { informAboutProtectedRouteInfo, receivedRequestForIgnoredRoute } from './errors'; import { redirectToSignIn } from './redirect'; import type { NextMiddlewareResult, WithAuthOptions } from './types'; import { isDevAccountPortalOrigin } from './url'; @@ -39,8 +34,6 @@ type RouteMatcherWithNextTypedRoutes = Autocomplete< WithPathPatternWildcard> | NextTypedRoute >; -const INFINITE_REDIRECTION_LOOP_COOKIE = '__clerk_redirection_loop'; - /** * The default ideal matcher that excludes the _next directory (internals) and all static files, * but it will match the root route (/) and any routes that start with /api or /trpc. @@ -191,22 +184,50 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { return setHeader(beforeAuthRes, constants.Headers.AuthReason, 'redirect'); } - const requestState = await authenticateRequest(req, options); - if (requestState.isUnknown) { - logger.debug('authenticateRequest state is unknown', requestState); - return handleUnknownState(requestState); - } else if (requestState.isInterstitial && isApiRoute(req)) { - logger.debug('authenticateRequest state is interstitial in an API route', requestState); - return handleUnknownState(requestState); - } else if (requestState.isInterstitial) { - logger.debug('authenticateRequest state is interstitial', requestState); + const devBrowserToken = + req.nextUrl.searchParams.get('__clerk_db_jwt') || req.cookies.get('__clerk_db_jwt')?.value || ''; + const handshakeToken = + req.nextUrl.searchParams.get('__clerk_handshake') || req.cookies.get('__clerk_handshake')?.value || ''; + + // TODO: fix type discrepancy between WithAuthOptions and AuthenticateRequestOptions + const requestState = await authenticateRequest(req, { + ...options, + devBrowserToken, + handshakeToken, + } as AuthenticateRequestOptions); + const requestStateHeaders = requestState.headers; + + const locationHeader = requestStateHeaders?.get('location'); - assertClockSkew(requestState, options); + // triggering a handshake redirect + if (locationHeader) { + return new Response(null, { status: 307, headers: requestStateHeaders }); + } - const res = handleInterstitialState(requestState, options); - return assertInfiniteRedirectionLoop(req, res, options, requestState); + if ( + requestState.status === AuthStatus.Handshake || + requestState.status === AuthStatus.Unknown || + requestState.status === AuthStatus.Interstitial + ) { + console.log(requestState); + throw new Error('Unexpected handshake or unknown state without redirect'); } + // if (requestState.isUnknown) { + // logger.debug('authenticateRequest state is unknown', requestState); + // return handleUnknownState(requestState); + // } else if (requestState.isInterstitial && isApiRoute(req)) { + // logger.debug('authenticateRequest state is interstitial in an API route', requestState); + // return handleUnknownState(requestState); + // } else if (requestState.isInterstitial) { + // logger.debug('authenticateRequest state is interstitial', requestState); + + // assertClockSkew(requestState, options); + + // const res = handleInterstitialState(requestState, options); + // return assertInfiniteRedirectionLoop(req, res, options, requestState); + // } + const auth = Object.assign(requestState.toAuth(), { isPublicRoute: isPublicRoute(req), isApiRoute: isApiRoute(req), @@ -227,7 +248,15 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { logger.debug(`Added ${constants.Headers.EnableDebug} on request`); } - return decorateRequest(req, finalRes, requestState); + const result = decorateRequest(req, finalRes, requestState) || NextResponse.next(); + + if (requestStateHeaders) { + requestStateHeaders.forEach((value, key) => { + result.headers.append(key, value); + }); + } + + return result; }); }; @@ -352,55 +381,6 @@ const isRequestMethodIndicatingApiRoute = (req: NextRequest): boolean => { return !['get', 'head', 'options'].includes(requestMethod); }; -/** - * In development, attempt to detect clock skew based on the requestState. This check should run when requestState.isInterstitial is true. If detected, we throw an error. - */ -const assertClockSkew = (requestState: RequestState, opts: AuthMiddlewareParams): void => { - if (!isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { - return; - } - - if (requestState.reason === TokenVerificationErrorReason.TokenNotActiveYet) { - throw new Error(clockSkewDetected(requestState.message)); - } -}; - -// When in development, we want to prevent infinite interstitial redirection loops. -// We incrementally set a `__clerk_redirection_loop` cookie, and when it loops 6 times, we throw an error. -// We also utilize the `referer` header to skip the prefetch requests. -const assertInfiniteRedirectionLoop = ( - req: NextRequest, - res: NextResponse, - opts: AuthMiddlewareParams, - requestState: RequestState, -): NextResponse => { - if (!isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { - return res; - } - - const infiniteRedirectsCounter = Number(req.cookies.get(INFINITE_REDIRECTION_LOOP_COOKIE)?.value) || 0; - if (infiniteRedirectsCounter === 6) { - // Infinite redirect detected, is it clock skew? - // We check for token-expired here because it can be a valid, recoverable scenario, but in a redirect loop a token-expired error likely indicates clock skew. - if (requestState.reason === TokenVerificationErrorReason.TokenExpired) { - throw new Error(clockSkewDetected(requestState.message)); - } - - // Not clock skew, return general error - throw new Error(infiniteRedirectLoopDetected()); - } - - // Skip the prefetch requests (when hovering a Next Link element) - if (req.headers.get('referer') === req.url) { - res.cookies.set({ - name: INFINITE_REDIRECTION_LOOP_COOKIE, - value: `${infiniteRedirectsCounter + 1}`, - maxAge: 3, - }); - } - return res; -}; - const withNormalizedClerkUrl = (req: NextRequest): WithClerkUrl => { const clerkUrl = req.nextUrl.clone(); diff --git a/packages/nextjs/src/server/authenticateRequest.ts b/packages/nextjs/src/server/authenticateRequest.ts index 8d995fb7d2d..1fa419895df 100644 --- a/packages/nextjs/src/server/authenticateRequest.ts +++ b/packages/nextjs/src/server/authenticateRequest.ts @@ -1,3 +1,4 @@ +import type { AuthenticateRequestOptions } from '@clerk/backend'; import { constants, debugRequestState } from '@clerk/backend'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -5,10 +6,9 @@ import { NextResponse } from 'next/server'; import type { RequestState } from './clerkClient'; import { clerkClient } from './clerkClient'; import { CLERK_JS_URL, CLERK_JS_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from './constants'; -import type { WithAuthOptions } from './types'; import { apiEndpointUnauthorizedNextResponse, handleMultiDomainAndProxy } from './utils'; -export const authenticateRequest = async (req: NextRequest, opts: WithAuthOptions) => { +export const authenticateRequest = async (req: NextRequest, opts: AuthenticateRequestOptions) => { const { isSatellite, domain, signInUrl, proxyUrl } = handleMultiDomainAndProxy(req, opts); return await clerkClient.authenticateRequest({ ...opts, @@ -34,7 +34,7 @@ export const handleUnknownState = (requestState: RequestState) => { return response; }; -export const handleInterstitialState = (requestState: RequestState, opts: WithAuthOptions) => { +export const handleInterstitialState = (requestState: RequestState, opts: AuthenticateRequestOptions) => { const response = new NextResponse( clerkClient.localInterstitial({ publishableKey: opts.publishableKey || PUBLISHABLE_KEY, diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index de745f5e9b9..6735ea023ed 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -1,4 +1,4 @@ -import type { RequestState } from '@clerk/backend'; +import type { AuthenticateRequestOptions, RequestState } from '@clerk/backend'; import { buildRequestUrl, constants } from '@clerk/backend'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; @@ -9,7 +9,7 @@ import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; import { DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; import { missingDomainAndProxy, missingSignInUrlInDev } from './errors'; -import type { NextMiddlewareResult, RequestLike, WithAuthOptions } from './types'; +import type { NextMiddlewareResult, RequestLike } from './types'; type AuthKey = 'AuthStatus' | 'AuthMessage' | 'AuthReason'; @@ -221,7 +221,7 @@ export const isCrossOrigin = (from: string | URL, to: string | URL) => { return fromUrl.origin !== toUrl.origin; }; -export const handleMultiDomainAndProxy = (req: NextRequest, opts: WithAuthOptions) => { +export const handleMultiDomainAndProxy = (req: NextRequest, opts: AuthenticateRequestOptions) => { const requestURL = buildRequestUrl(req); const relativeOrAbsoluteProxyUrl = handleValueOrFn(opts?.proxyUrl, requestURL, PROXY_URL); let proxyUrl; diff --git a/packages/shared/src/devBrowser.ts b/packages/shared/src/devBrowser.ts index 8f94b099856..ef78863342b 100644 --- a/packages/shared/src/devBrowser.ts +++ b/packages/shared/src/devBrowser.ts @@ -1,4 +1,4 @@ -export const DEV_BROWSER_SSO_JWT_PARAMETER = '__dev_session'; +export const DEV_BROWSER_SSO_JWT_PARAMETER = '__clerk_db_jwt'; export const DEV_BROWSER_JWT_MARKER = '__clerk_db_jwt'; export const DEV_BROWSER_SSO_JWT_KEY = 'clerk-db-jwt'; From 184792f05fc0f40578616d87b986786feba79bf5 Mon Sep 17 00:00:00 2001 From: Colin Sidoti <51144033+colinclerk@users.noreply.github.com> Date: Tue, 5 Dec 2023 13:15:32 +0200 Subject: [PATCH 03/19] feat(repo): Introduce tests for client handshake (#2265) * Test suite start * feat(backend,nextjs,utils): Fix jest * first test * Fix bug in jwks cache for multiple runtime keys * Add all the tests, including many failing * Add all the tests, including many failing * fix(shared): Correctly construct proxy URL --------- Co-authored-by: Nikos Douvlis Co-authored-by: Bryce Kalow --- handshake.test.ts | 653 ++++++++++++++++++ handshakeTestConfigs.ts | 145 ++++ .../templates/next-app-router/package.json | 5 + jest.config.handshake.js | 19 + packages/backend/src/tokens/authStatus.ts | 2 +- packages/backend/src/tokens/errors.ts | 1 - .../backend/src/tokens/interstitialRule.ts | 14 +- .../backend/src/tokens/jwt/assertions.test.ts | 36 - packages/backend/src/tokens/jwt/assertions.ts | 14 - packages/backend/src/tokens/jwt/verifyJwt.ts | 6 +- packages/backend/src/tokens/keys.ts | 19 +- packages/backend/src/tokens/request.ts | 125 ++-- packages/backend/src/tokens/verify.ts | 15 +- .../backend/src/util/IsomorphicRequest.ts | 7 +- packages/backend/src/utils.test.ts | 5 - packages/backend/src/utils.ts | 7 +- playground/nextjs/middleware.ts | 12 +- playground/nextjs/package.json | 10 +- 18 files changed, 899 insertions(+), 196 deletions(-) create mode 100644 handshake.test.ts create mode 100644 handshakeTestConfigs.ts create mode 100644 jest.config.handshake.js diff --git a/handshake.test.ts b/handshake.test.ts new file mode 100644 index 00000000000..d9add2427a2 --- /dev/null +++ b/handshake.test.ts @@ -0,0 +1,653 @@ +// @ts-ignore ignore types +import * as http from 'http'; +import { generateConfig, getJwksFromSecretKey } from './handshakeTestConfigs'; + +const urlArg = process.argv.find(x => x.startsWith('--url='))?.replace('--url=', ''); +if (!urlArg) { + throw new Error('Must pass URL like: --url=http://localhost:4011'); +} + +// Strip trailing slash +const url = new URL(urlArg).origin; +const devBrowserCookie = '__clerk_db_jwt=needstobeset;'; +const devBrowserQuery = '&__clerk_db_jwt=needstobeset'; + +//create a server object: +const server = http.createServer(function (req, res) { + const sk = req.headers.authorization?.replace('Bearer ', ''); + if (!sk) { + console.log('No SK to', req.url, req.headers); + } + + res.setHeader('Content-Type', 'application/json'); + res.write(JSON.stringify(getJwksFromSecretKey(sk))); //write a response to the client + res.end(); //end the response +}); + +beforeAll(() => { + console.log( + 'Starting jwks service on 127.0.0.1:4199.\nMake sure the framework has CLERK_API_URL set to http://localhost:4199', + ); + server.listen(4199); + + console.log('Running tests against ', url); +}); + +afterAll(() => { + server.close(); + setImmediate(function () { + server.emit('close'); + }); +}); + +test('Test standard signed-in - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test standard signed-in - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test expired session token - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + ); +}); + +test('Test expired session token - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + ); +}); + +test('Test early session token - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'early' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + ); +}); + +test('Test proxyUrl - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Proxy-Url': 'https://example.com/clerk', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://example.com/clerk/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + ); +}); + +test('Test proxyUrl - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Proxy-Url': 'https://example.com/clerk', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://example.com/clerk/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + ); +}); + +test('Test domain - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Domain': 'localhost:3000', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + ); +}); + +test('Test domain - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Domain': 'example.com', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://clerk.example.com/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + ); +}); + +test('Test missing session token, positive uat - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=1`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + ); +}); + +test('Test missing session token, positive uat - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=1`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + ); +}); + +test('Test missing session token, 0 uat (indicating signed out) - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=0`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test missing session token, 0 uat (indicating signed out) - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=0`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test missing session token, missing uat (indicating signed out) - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test missing session token, missing uat (indicating signed out) - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test redirect url - path and qs - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + ); +}); + +test('Test redirect url - path and qs - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2Fhello%3Ffoo%3Dbar`, + ); +}); + +test('Test redirect url - proxy - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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${devBrowserQuery}`, + ); +}); + +test('Test redirect url - proxy - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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`, + ); +}); + +test('Test redirect url - proxy with port - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com:3213', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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${devBrowserQuery}`, + ); +}); + +test('Test redirect url - proxy with port - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/hello?foo=bar', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat}; __session=${token}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Forwarded-Host': 'example.com:3213', + 'X-Forwarded-Proto': 'https', + }), + redirect: 'manual', + }); + 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`, + ); +}); + +test('Handshake result - dev - nominal', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); +}); + +test('Handshake result - dev - skew - clock behind', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'early' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); + +test('Handshake result - dev - skew - clock ahead', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'expired' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); + +test('Handshake result - dev - mismatched keys', async () => { + const config = generateConfig({ + mode: 'test', + matchedKeys: false, + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); + +// I don't know if we need this one? We might pass new devbrowser back in handshake +test('Handshake result - dev - new devbrowser', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, '__clerk_db_jwt=asdf;path=/']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/?__clerk_handshake=' + handshake, { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); +}); + +test('External visit - new devbrowser', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/?__clerk_db_jwt=asdf', { + headers: new Headers({ + Cookie: `${devBrowserCookie}`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F&__clerk_db_jwt=asdf`, + ); +}); + +test('Handshake result - prod - nominal', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); + const headers = [...res.headers.entries()]; + cookiesToSet.forEach(cookie => { + expect(headers).toContainEqual(['set-cookie', cookie]); + }); +}); + +test('Handshake result - prod - skew - clock behind', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'early' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); + +test('Handshake result - prod - skew - clock ahead', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token } = config.generateToken({ state: 'expired' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); + +test('Handshake result - prod - mismatched keys', async () => { + const config = generateConfig({ + mode: 'live', + matchedKeys: false, + }); + const { token } = config.generateToken({ state: 'active' }); + const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; + const handshake = btoa(JSON.stringify(cookiesToSet)); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Cookie: `__clerk_handshake=${handshake}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(500); +}); diff --git a/handshakeTestConfigs.ts b/handshakeTestConfigs.ts new file mode 100644 index 00000000000..124f7d0727b --- /dev/null +++ b/handshakeTestConfigs.ts @@ -0,0 +1,145 @@ +// @ts-ignore ignore types +import * as jwt from 'jsonwebtoken'; +// @ts-ignore ignore types +import * as uuid from 'uuid'; + +const rsaPairs = { + a: { + private: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAlRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1w +FpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW +3ehNAm0svievMfKtG6UjjYz6v67U9Om/oMt1ehsOmR8MrDYvs3Wy+dxYpZaxyn6w +ajL7GkICHxc8cGsI/MBZr9jKtKyzFY++r8TQKAJwn9TcSQljRivomz1wQvjtdLnq +ZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY+ifAITt7MqEcvast14AP0rICBSq +vbQQjZuwLgIrlJgqvJ4YBRfIaIx/qQzs0+eFtwIDAQABAoIBAD1H4xTqfWsZR1fF +SWBylDqSaxKNRPCZ3ApqEq58IjFZf/oPyiJPRGg2IMUXC3RbnrnAmAsGjHkdcj/s +HpjZZKQKNv/1NKo41vxyPcWoAsVJgYzd51liEr2rmNe1QkuawFN7xyh5Sd0fBYSC +zPVQjMKbep2waKolP+hZui8AxyORLtu6aUQawaCdWFyiyHtqEnlcb/YSTtGl/3W/ +/LqYyv60dG0QdcAO37MAE42vp3R4GGcJelsFo/lxSKg+KiLn7NdsNr7bCJrqbVXz +93Fu5jgHQD9+BeVyvHJ/R2yg+utYEvIMiFvwX7z4MLh9PsWJbf9vbDNlw9ErWpf5 +r1xUiqECgYEA/lLJP+qla0vd+ocYNe3ufOG4kaUFsrqRoChiS1JxwQr/WGTmV8sT +ZyTPwyxnsHtbzn4lwuI6CpAeyvd9O6G3FfTzUqsyPaknsGlymf8LEwL4AVo0BVY0 +YGodRnDISBBU/yPQ2kvq6c72ouq5cQxWF45f8Z/Z+fFDjuHG6Q44hYcCgYEAlhD6 +sm8wTWVklMAxOnhQJoseWQ1vcl6VCxRVv4QBiX9CQm/4ANVOWun4KRC5qqTdoNHc +RyuiWpZVgGblqUu4sWSQgi3CZyyLbHOJ9wTPTeo0oDVaFa9MMwS8rq35HXjpgREz +JtTRi6c9WVsjBygYiE5IYO0FGbEjI9qIiD5CClECgYA+wtVRRamu0dkk0yPhYycg +gF+Y6Z1/XtVDLdQb/GuAFSOwf63sanwOTyJKavHntnmQesb80fE63BgNRIgOKDlT +XNCTTRYn60+VFGCoqizkcy4av1TpID3qsSUqVfjG9+jR0dffly6Qpnds+vnqcP3p +8EOzEByttqFSaFs69jxyjwKBgFCQbQa+isACXy08wTESxnTq2zAT9nEANiPsltxq +kiivGXNxiUNpQNeuJHxnbkYenJ1qDUhoNJFNhDmbBFEPReh2hN5ekq+xSmi+3qKv +AlxiED6yZdqecdoyANoGrGcWMsYH5d5DAvxmnJkMRJHjBMiovlLK7KIOZz8oY4RB +aFMBAoGBAJ8UoGHwz7AdOLAldGY3c0HzaH1NaGrZoocSH19XI3ZYr8Qvl2XUww9G +UC1OG4e2PtZ8PINQGIYPacUaab5Yg1tOmxBoAx4gUkpgyjtSm2ZPd4EUVOdylU3E +aFa08+0FF7mqqJTgz5XlvHMrCcUTsJ9u+e05rr1G1PHsATuuMD9m +-----END RSA PRIVATE KEY-----`, + public: { + kty: 'RSA', + n: 'lRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1wFpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW3ehNAm0svievMfKtG6UjjYz6v67U9Om_oMt1ehsOmR8MrDYvs3Wy-dxYpZaxyn6wajL7GkICHxc8cGsI_MBZr9jKtKyzFY--r8TQKAJwn9TcSQljRivomz1wQvjtdLnqZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY-ifAITt7MqEcvast14AP0rICBSqvbQQjZuwLgIrlJgqvJ4YBRfIaIx_qQzs0-eFtw', + e: 'AQAB', + }, + }, + b: { + private: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAt9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSa +Jryjhkzpyd16c+PDo+FFtMgZTUv6Z2hr5QYMuAjlsM+apHmfE8MRMQRQHXNF0+sE +Bd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKH +oVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8+Uy5ybLlk1qSlQ5lsymfW +hxs0C9j7/x/h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1 +H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N+rGwIDAQABAoIBAGLGdx/xGp9IWrP8 +nCBuyXMmPYyYwTJ8tmDpsI9mMo6tV3a5wrbc0NztpQuVuJtZ2VjJRTGB7lXgY336 +UzyOq3aTERKT9Xg2/ocXXL0AnCm2K+VVdKvR9nTbLlKA+E6xRe5te4YIDaPkb1q/ +a4VQfCQblDAtYhFUzfKsXCGCRJ8IPlhZxiA9RHfQTmQUSoBW+12IovyMdMxVLoPT +qjdnwL1TS3iARim+eV+buHW+8Drn8eldeSFoTJd0B9eRf7pMpRH/X8G7X0YYdDjF +ADWI770CQj45QQeuVsZYIuONIPmzai4nGiNQ85v+Yy0L47lYUp5XsDvwYO5tMCQK +v1og8cECgYEA7zIfBWM1AIY763FGJ6F5ym41A4i7WSRaWusfQx6fOdGTyHJ3hXu9 ++1kQS97IKElJ1V7oK69dJGxq+aupsd/AaRJb4oVZCBSby4Fo6VeJoKyJSdNSCks6 +tonT3hGUsJO1ER2ItWcgiCxgGY+vrK0rkacX/VgNZKGIjlGv8pQUpaUCgYEAxMeL +2jyx+5odGM51e/1G4Vn6t+ffNNC/03NbwZeJ93+0NPgdagIo1kErZQrFPEfoExOr +KMkwnAsnR/xfn4X21voK1pUc7VhzzoODb8l9LA9kB7efWtRZA79gcsbOH5wNkp9Z +i76AtaVU/p1grFKNcnes1lbFfcRUnO880g5dsb8CgYBacuuEEAWk0x2pZEYRCmCR +iacGVRfzF2oLY0mJCfVP2c42SAKmOSqX9w/QgMfTZBNFWgQVMNTZxx2Ul7Mtjdym +XsjcGWyXP6PCCodvZSin11Z60iv9tIDZMbkqCh/dvZ0EgdSGNB77HzyfrdPSShFl +nHfX1woJeYO3vW/5HMHJ+QKBgQCNema7pq3Ulq5a2n2vgp9GgJn5RXW+lGOG1Mbg +vmJMlv1qpAUJ5bmUqdBYWlEKkSxzIs4JifUwC/jXEcVyfS/GyommVBkzMEg672U9 +pyEe34Xs4oFpHYlOX3cprnQeV+WOSJFqHrKNZuxgD6ik3MmjxhV3GXXugYzQNFWH +NRr6IwKBgH9aN5mY4fcVL76mMEVZ5BIHE+JpPMZ6OOamOHAiA5jrWRX4aRMICq3t +cKVfcj/M4dyBuRV5EW1y1m2QhRECFPSKpScykpD9nyCb+XqbMSLH+f+j1BGfLKWl +t5o8u/dlwJ1fGGday48gs/hA4V/F9zDjecNkYWUB/wUwVStqZljn +-----END RSA PRIVATE KEY-----`, + public: { + kty: 'RSA', + n: 't9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSaJryjhkzpyd16c-PDo-FFtMgZTUv6Z2hr5QYMuAjlsM-apHmfE8MRMQRQHXNF0-sEBd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKHoVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8-Uy5ybLlk1qSlQ5lsymfWhxs0C9j7_x_h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N-rGw', + e: 'AQAB', + }, + }, +}; + +const allConfigs: any = []; + +export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'live'; matchedKeys?: boolean }) { + const ins_id = uuid.v4(); + const pkHost = `clerk.${uuid.v4()}.com`; + const pk = `pk_${mode}_${btoa(`${pkHost}$`)}`; + const sk = `sk_${mode}_${uuid.v4()}`; + const rsa = matchedKeys + ? rsaPairs.a + : { + private: rsaPairs.a.private, + public: rsaPairs.b.public, + }; + const jwks = { + keys: [ + { + ...rsa.public, + kid: ins_id, + use: 'sig', + alg: 'RS256', + }, + ], + }; + + type Claims = { + sub: string; + iat: number; + exp: number; + nbf: number; + }; + const generateToken = ({ state }: { state: 'active' | 'expired' | 'early' }) => { + let claims = { sub: 'user_12345' } as Claims; + + const now = Math.floor(Date.now() / 1000); + if (state === 'active') { + claims.iat = now; + claims.nbf = now - 10; + claims.exp = now + 60; + } else if (state === 'expired') { + claims.iat = now - 600; + claims.nbf = now - 10 - 600; + claims.exp = now + 60 - 600; + } else if (state === 'early') { + claims.iat = now + 600; + claims.nbf = now - 10 + 600; + claims.exp = now + 60 + 600; + } + return { + token: jwt.sign(claims, rsa.private, { + algorithm: 'RS256', + header: { kid: ins_id }, + }), + claims, + }; + }; + const config = Object.freeze({ + pk, + sk, + generateToken, + jwks, + pkHost, + }); + allConfigs.push(config); + return config; +} + +export function getJwksFromSecretKey(sk: any) { + return allConfigs.find((x: any) => x.sk === sk)?.jwks; +} diff --git a/integration/templates/next-app-router/package.json b/integration/templates/next-app-router/package.json index 5e298c001bd..eef4565f52c 100644 --- a/integration/templates/next-app-router/package.json +++ b/integration/templates/next-app-router/package.json @@ -9,6 +9,11 @@ "start": "next start" }, "dependencies": { + "@clerk/backend": "file:.yalc/@clerk/backend", + "@clerk/clerk-react": "file:.yalc/@clerk/clerk-react", + "@clerk/nextjs": "file:.yalc/@clerk/nextjs", + "@clerk/shared": "file:.yalc/@clerk/shared", + "@clerk/types": "file:.yalc/@clerk/types", "@types/node": "^18.17.0", "@types/react": "18.2.14", "@types/react-dom": "18.2.6", diff --git a/jest.config.handshake.js b/jest.config.handshake.js new file mode 100644 index 00000000000..e5897f4fe9e --- /dev/null +++ b/jest.config.handshake.js @@ -0,0 +1,19 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + extensionsToTreatAsEsm: ['.ts'], + testRegex: ['handshake.test.tsx?$'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` + '^.+\\.tsx?$': [ + 'ts-jest', + { + diagnostics: false, + useESM: true, + }, + ], + }, +}; diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index e19d094e6e5..02b35beccc2 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -105,7 +105,7 @@ type LoadResourcesOptions = { }; type RequestStateParams = { - publishableKey?: string; + publishableKey: string; domain?: string; isSatellite?: boolean; proxyUrl?: string; diff --git a/packages/backend/src/tokens/errors.ts b/packages/backend/src/tokens/errors.ts index 8c3f8a02494..bb7757fa5fc 100644 --- a/packages/backend/src/tokens/errors.ts +++ b/packages/backend/src/tokens/errors.ts @@ -9,7 +9,6 @@ export enum TokenVerificationErrorReason { TokenInvalid = 'token-invalid', TokenInvalidAlgorithm = 'token-invalid-algorithm', TokenInvalidAuthorizedParties = 'token-invalid-authorized-parties', - TokenInvalidIssuer = 'token-invalid-issuer', TokenInvalidSignature = 'token-invalid-signature', TokenNotActiveYet = 'token-not-active-yet', TokenVerificationFailed = 'token-verification-failed', diff --git a/packages/backend/src/tokens/interstitialRule.ts b/packages/backend/src/tokens/interstitialRule.ts index cfc92670081..503f214b543 100644 --- a/packages/backend/src/tokens/interstitialRule.ts +++ b/packages/backend/src/tokens/interstitialRule.ts @@ -25,6 +25,8 @@ export type InterstitialRuleOptions = AuthStatusOptionsType & { headerToken?: string; /* Request search params value */ searchParams?: URLSearchParams; + /* Derived Request URL */ + derivedRequestUrl?: URL; }; type InterstitialRuleResult = RequestState | undefined; @@ -171,17 +173,7 @@ export async function runInterstitialRules( } async function verifyRequestState(options: InterstitialRuleOptions, token: string) { - const { isSatellite, proxyUrl } = options; - let issuer; - if (isSatellite) { - issuer = null; - } else if (proxyUrl) { - issuer = proxyUrl; - } else { - issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); - } - - return verifyToken(token, { ...options, issuer }); + return verifyToken(token, { ...options }); } /** diff --git a/packages/backend/src/tokens/jwt/assertions.test.ts b/packages/backend/src/tokens/jwt/assertions.test.ts index 71b95e7066d..69a0c5be57d 100644 --- a/packages/backend/src/tokens/jwt/assertions.test.ts +++ b/packages/backend/src/tokens/jwt/assertions.test.ts @@ -232,42 +232,6 @@ export default (QUnit: QUnit) => { }); }); - module('assertIssuerClaim(iss, issuer)', () => { - test('does not throw if issuer is null', assert => { - assert.equal(undefined, assertIssuerClaim('', null)); - }); - - test('throws error if iss does not match with issuer string', assert => { - assert.raises( - () => assertIssuerClaim('issuer', ''), - new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "".`), - ); - assert.raises( - () => assertIssuerClaim('issuer', 'issuer-2'), - new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "issuer-2".`), - ); - }); - - test('throws error if iss does not match with issuer function result', assert => { - assert.raises( - () => assertIssuerClaim('issuer', () => false), - new Error(`Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.`), - ); - }); - - test('does not throw if iss matches issuer ', assert => { - assert.equal(undefined, assertIssuerClaim('issuer', 'issuer')); - assert.equal( - undefined, - assertIssuerClaim('issuer', s => s === 'issuer'), - ); - assert.equal( - undefined, - assertIssuerClaim('issuer', () => true), - ); - }); - }); - module('assertExpirationClaim(exp, clockSkewInMs)', hooks => { let fakeClock; hooks.beforeEach(() => { diff --git a/packages/backend/src/tokens/jwt/assertions.ts b/packages/backend/src/tokens/jwt/assertions.ts index aeb4029fe45..f91e9b2636f 100644 --- a/packages/backend/src/tokens/jwt/assertions.ts +++ b/packages/backend/src/tokens/jwt/assertions.ts @@ -94,20 +94,6 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s } }; -export const assertIssuerClaim = (iss: string, issuer: IssuerResolver | null) => { - if (typeof issuer === 'function' && !issuer(iss)) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalidIssuer, - message: 'Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.', - }); - } else if (typeof issuer === 'string' && iss && iss !== issuer) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalidIssuer, - message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuer}".`, - }); - } -}; - export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => { if (typeof exp !== 'number') { throw new TokenVerificationError({ diff --git a/packages/backend/src/tokens/jwt/verifyJwt.ts b/packages/backend/src/tokens/jwt/verifyJwt.ts index 874d616210b..2fd22c53c40 100644 --- a/packages/backend/src/tokens/jwt/verifyJwt.ts +++ b/packages/backend/src/tokens/jwt/verifyJwt.ts @@ -6,7 +6,6 @@ import runtime from '../../runtime'; import { base64url } from '../../util/rfc4648'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import { getCryptoAlgorithm } from './algorithms'; -import type { IssuerResolver } from './assertions'; import { assertActivationClaim, assertAudienceClaim, @@ -15,7 +14,6 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, - assertIssuerClaim, assertSubClaim, } from './assertions'; import { importKey } from './cryptoKeys'; @@ -82,13 +80,12 @@ export type VerifyJwtOptions = { audience?: string | string[]; authorizedParties?: string[]; clockSkewInMs?: number; - issuer: IssuerResolver | string | null; key: JsonWebKey | string; }; export async function verifyJwt( token: string, - { audience, authorizedParties, clockSkewInMs, issuer, key }: VerifyJwtOptions, + { audience, authorizedParties, clockSkewInMs, key }: VerifyJwtOptions, ): Promise { const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_SECONDS; @@ -108,7 +105,6 @@ export async function verifyJwt( assertSubClaim(sub); assertAudienceClaim([aud], [audience]); assertAuthorizedPartiesClaim(azp, authorizedParties); - assertIssuerClaim(iss, issuer); assertExpirationClaim(exp, clockSkew); assertActivationClaim(nbf, clockSkew); assertIssuedAtClaim(iat, clockSkew); diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 41030f2287f..972d10fad12 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -98,7 +98,6 @@ export type LoadClerkJWKFromRemoteOptions = { secretKey?: string; apiUrl?: string; apiVersion?: string; - issuer?: string; }; /** @@ -108,7 +107,6 @@ export type LoadClerkJWKFromRemoteOptions = { * The cache lasts 1 hour by default. * * @param {Object} options - * @param {string} options.issuer - The issuer origin of the JWT * @param {string} options.kid - The id of the key that the JWT was signed with * @param {string} options.alg - The algorithm of the JWT * @param {number} options.jwksCacheTtlInMs - The TTL of the jwks cache (defaults to 1 hour) @@ -118,27 +116,20 @@ export async function loadClerkJWKFromRemote({ secretKey, apiUrl = API_URL, apiVersion = API_VERSION, - issuer, kid, jwksCacheTtlInMs = JWKS_CACHE_TTL_MS, skipJwksCache, }: LoadClerkJWKFromRemoteOptions): Promise { - const shouldRefreshCache = !getFromCache(kid) && reachedMaxCacheUpdatedAt(); - if (skipJwksCache || shouldRefreshCache) { - let fetcher; - - if (secretKey) { - fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion); - } else if (issuer) { - fetcher = () => fetchJWKSFromFAPI(issuer); - } else { + const needsFetch = !getFromCache(kid) || cacheHasExpired(); + if (skipJwksCache || needsFetch) { + if (!secretKey) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.ContactSupport, message: 'Failed to load JWKS from Clerk Backend or Frontend API.', reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, }); } - + const fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion); const { keys } = await callWithRetry<{ keys: JsonWebKeyWithKid[] }>(fetcher); if (!keys || !keys.length) { @@ -231,6 +222,6 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string return response.json(); } -function reachedMaxCacheUpdatedAt() { +function cacheHasExpired() { return Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000; } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 81b7fdb4fb4..84d25d8fb0b 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,10 +1,10 @@ import { parsePublishableKey } from '@clerk/shared/keys'; +import { Token } from 'src'; import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; import { addClerkPrefix, isDevelopmentFromSecretKey, isDevOrStagingUrl } from '../util/shared'; -import { buildRequestUrl } from '../utils'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; import { AuthErrorReason, handshake, interstitial, signedIn, signedOut, unknownState } from './authStatus'; import type { TokenCarrier } from './errors'; @@ -49,15 +49,24 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } export async function authenticateRequest(options: AuthenticateRequestOptions): Promise { - const { cookies, headers, searchParams } = buildRequest(options?.request); + const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(options.request); const ruleOptions = { ...options, ...loadOptionsFromHeaders(headers), ...loadOptionsFromCookies(cookies), searchParams, + derivedRequestUrl, } satisfies InterstitialRuleOptions; + // TODO: Get types in a better place, there's definitely a pk here + const pk = parsePublishableKey(options.publishableKey); + if (!pk) { + throw new Error('no pk'); + } + + const instanceType = pk.instanceType; + assertValidSecretKey(ruleOptions.secretKey); if (ruleOptions.isSatellite) { @@ -81,23 +90,20 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): function buildRedirectToHandshake({ publishableKey, devBrowserToken, - redirectUrl, - domain, + derivedRequestUrl, proxyUrl, - }: { - publishableKey: string; - devBrowserToken: string; - redirectUrl: string; - domain?: string; - proxyUrl?: string; - }): string { + domain, + }: InterstitialRuleOptions): string { + const redirectUrl = new URL(derivedRequestUrl as URL); + redirectUrl.searchParams.delete('__clerk_db_jwt'); const pk = parsePublishableKey(publishableKey); const pkFapi = pk?.frontendApi || ''; // determine proper FAPI url, taking into account multi-domain setups - const frontendApi = proxyUrl || (!isDevOrStagingUrl(pkFapi) ? addClerkPrefix(domain) : '') || pkFapi; + const frontendApi = proxyUrl || (pk?.instanceType !== 'development' ? addClerkPrefix(domain) : '') || pkFapi; + const frontendApiNoProtocol = frontendApi.replace(/http(s)?:\/\//, ''); - const url = new URL(`https://${frontendApi}/v1/client/handshake`); - url.searchParams.append('redirect_url', redirectUrl); + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); if (pk?.instanceType === 'development' && devBrowserToken) { url.searchParams.append('__clerk_db_jwt', devBrowserToken); @@ -105,20 +111,6 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): return url.href; } - async function verifyRequestState(options: InterstitialRuleOptions, token: string) { - const { isSatellite, proxyUrl } = options; - let issuer; - if (isSatellite) { - issuer = null; - } else if (proxyUrl) { - issuer = proxyUrl; - } else { - issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); - } - - return verifyToken(token, { ...options, issuer }); - } - const clientUat = parseInt(ruleOptions.clientUat || '', 10) || 0; const hasActiveClient = clientUat > 0; // TODO rename this to sessionToken @@ -143,11 +135,12 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } }); + // Right after a handshake is a good time to detect the skew + // const detectedSkew if (parsePublishableKey(options.publishableKey)?.instanceType === 'development') { - const newUrl = new URL(options.request.url); + const newUrl = new URL(ruleOptions.derivedRequestUrl); newUrl.searchParams.delete('__clerk_handshake'); newUrl.searchParams.delete('__clerk_help'); - headers.append('Location', newUrl.toString()); } @@ -155,30 +148,26 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); } - try { - const verifyResult = await verifyRequestState(ruleOptions, sessionToken); - if (verifyResult) { - return signedIn(ruleOptions, verifyResult, headers); - } - } catch (err) { - return signedOut(ruleOptions, AuthErrorReason.ClockSkew, '', headers); + // Uncaught + const verifyResult = await verifyToken(sessionToken, ruleOptions); + + if (verifyResult) { + return signedIn(ruleOptions, verifyResult, headers); } } // ================ This is to start the handshake if necessary =================== - if (ruleOptions.isSatellite && !new URL(options.request.url).searchParams.has('__clerk_synced')) { - const redirectUrl = buildRequestUrl(options.request); + if (instanceType === 'development' && ruleOptions.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + + if (ruleOptions.isSatellite && !ruleOptions.derivedRequestUrl.searchParams.has('__clerk_synced')) { const headers = new Headers(); - headers.set( - 'Location', - buildRedirectToHandshake({ - publishableKey: ruleOptions.publishableKey!, - devBrowserToken: ruleOptions.devBrowserToken!, - redirectUrl: redirectUrl.toString(), - proxyUrl: ruleOptions.proxyUrl, - domain: ruleOptions.domain, - }), - ); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); // TODO: Add status code for redirection return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); @@ -191,14 +180,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): // This can eagerly run handshake since client_uat is SameSite=Strict in dev if (!hasActiveClient && hasSessionToken) { const headers = new Headers(); - headers.set( - 'Location', - buildRedirectToHandshake({ - publishableKey: ruleOptions.publishableKey!, - devBrowserToken: ruleOptions.devBrowserToken!, - redirectUrl: ruleOptions.request.url.toString(), - }), - ); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); // TODO: Add status code for redirection return handshake(ruleOptions, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); @@ -206,14 +188,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): if (hasActiveClient && !hasSessionToken) { const headers = new Headers(); - headers.set( - 'Location', - buildRedirectToHandshake({ - publishableKey: ruleOptions.publishableKey!, - devBrowserToken: ruleOptions.devBrowserToken!, - redirectUrl: ruleOptions.request.url.toString(), - }), - ); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); // TODO: Add status code for redirection return handshake(ruleOptions, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); @@ -223,21 +198,14 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): if (decodeResult.payload.iat < clientUat) { const headers = new Headers(); - headers.set( - 'Location', - buildRedirectToHandshake({ - publishableKey: ruleOptions.publishableKey!, - devBrowserToken: ruleOptions.devBrowserToken!, - redirectUrl: ruleOptions.request.url.toString(), - }), - ); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); // TODO: Add status code for redirection return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); } try { - const verifyResult = await verifyRequestState(ruleOptions, sessionToken!); + const verifyResult = await verifyToken(sessionToken!, ruleOptions); if (verifyResult) { return signedIn(ruleOptions, verifyResult); } @@ -252,14 +220,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): if (reasonToHandshake) { const headers = new Headers(); - headers.set( - 'Location', - buildRedirectToHandshake({ - publishableKey: ruleOptions.publishableKey!, - devBrowserToken: ruleOptions.devBrowserToken!, - redirectUrl: ruleOptions.request.url.toString(), - }), - ); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); // TODO: Add status code for redirection return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index dafe984910f..9801ed30e42 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -9,13 +9,9 @@ import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; /** * */ -export type VerifyTokenOptions = Pick< - VerifyJwtOptions, - 'authorizedParties' | 'audience' | 'issuer' | 'clockSkewInMs' -> & { jwtKey?: string; proxyUrl?: string } & Pick< - LoadClerkJWKFromRemoteOptions, - 'secretKey' | 'apiUrl' | 'apiVersion' | 'jwksCacheTtlInMs' | 'skipJwksCache' - >; +export type VerifyTokenOptions = Pick & { + jwtKey?: string; +} & Pick; export async function verifyToken(token: string, options: VerifyTokenOptions): Promise { const { @@ -25,7 +21,6 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P audience, authorizedParties, clockSkewInMs, - issuer, jwksCacheTtlInMs, jwtKey, skipJwksCache, @@ -38,9 +33,6 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P if (jwtKey) { key = loadClerkJWKFromLocal(jwtKey); - } else if (typeof issuer === 'string') { - // Fetch JWKS from Frontend API if an issuer of type string has been provided - key = await loadClerkJWKFromRemote({ issuer, kid, jwksCacheTtlInMs, skipJwksCache }); } else if (secretKey) { // Fetch JWKS from Backend API using the key key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); @@ -57,6 +49,5 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P authorizedParties, clockSkewInMs, key, - issuer, }); } diff --git a/packages/backend/src/util/IsomorphicRequest.ts b/packages/backend/src/util/IsomorphicRequest.ts index 00028a4076d..7ac783560c1 100644 --- a/packages/backend/src/util/IsomorphicRequest.ts +++ b/packages/backend/src/util/IsomorphicRequest.ts @@ -11,18 +11,17 @@ export const createIsomorphicRequest = (cb: IsomorphicRequestOptions): Request = return new runtime.Request(headersGeneratedURL, req); }; -export const buildRequest = (req?: Request) => { - if (!req) { - return {}; - } +export const buildRequest = (req: Request) => { const cookies = parseIsomorphicRequestCookies(req); const headers = getHeaderFromIsomorphicRequest(req); const searchParams = getSearchParamsFromIsomorphicRequest(req); + const derivedRequestUrl = buildRequestUrl(req); return { cookies, headers, searchParams, + derivedRequestUrl, }; }; diff --git a/packages/backend/src/utils.test.ts b/packages/backend/src/utils.test.ts index 9114d8cb815..d76615d66a6 100644 --- a/packages/backend/src/utils.test.ts +++ b/packages/backend/src/utils.test.ts @@ -105,11 +105,6 @@ export default (QUnit: QUnit) => { assert.equal(buildRequestUrl(req), 'https://example.com/path'); }); - test('with path', assert => { - const req = new Request('http://localhost:3000/path'); - assert.equal(buildRequestUrl(req, '/other-path'), 'http://localhost:3000/other-path'); - }); - test('with query params in request', assert => { const req = new Request('http://localhost:3000/path'); assert.equal(buildRequestUrl(req), 'http://localhost:3000/path'); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 85e656074c5..62857cc5b95 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -3,18 +3,19 @@ import { constants } from './constants'; const getHeader = (req: Request, key: string) => req.headers.get(key); const getFirstValueFromHeader = (value?: string | null) => value?.split(',')[0]; -type BuildRequestUrl = (request: Request, path?: string) => URL; -export const buildRequestUrl: BuildRequestUrl = (request, path) => { +type BuildRequestUrl = (request: Request) => URL; +export const buildRequestUrl: BuildRequestUrl = request => { const initialUrl = new URL(request.url); const forwardedProto = getHeader(request, constants.Headers.ForwardedProto); const forwardedHost = getHeader(request, constants.Headers.ForwardedHost); + const host = getHeader(request, constants.Headers.Host); const protocol = initialUrl.protocol; const base = buildOrigin({ protocol, forwardedProto, forwardedHost, host: host || initialUrl.host }); - return new URL(path || initialUrl.pathname, base); + return new URL(initialUrl.pathname + initialUrl.search, base); }; type BuildOriginParams = { diff --git a/playground/nextjs/middleware.ts b/playground/nextjs/middleware.ts index e41136e55ed..a55a8020498 100644 --- a/playground/nextjs/middleware.ts +++ b/playground/nextjs/middleware.ts @@ -3,9 +3,15 @@ import { authMiddleware } from '@clerk/nextjs/server'; // Set the paths that don't require the user to be signed in const publicPaths = ['/', /^(\/(sign-in|sign-up|app-dir|custom)\/*).*$/]; -export default authMiddleware({ - publicRoutes: publicPaths, -}); +export const middleware = (req, evt) => { + return authMiddleware({ + publicRoutes: publicPaths, + publishableKey: req.headers.get("x-publishable-key"), + secretKey: req.headers.get("x-secret-key"), + proxyUrl: req.headers.get("x-proxy-url"), + domain: req.headers.get("x-domain"), + })(req, evt) +}; export const config = { matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'], diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 4c304acd971..f2d9cf5c3d5 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -9,13 +9,13 @@ "lint": "next lint" }, "dependencies": { - "@clerk/backend": "latest", - "@clerk/clerk-react": "latest", + "@clerk/backend": "file:.yalc/@clerk/backend", + "@clerk/clerk-react": "file:.yalc/@clerk/clerk-react", "@clerk/clerk-sdk-node": "latest", - "@clerk/nextjs": "latest", - "@clerk/shared": "latest", + "@clerk/nextjs": "file:.yalc/@clerk/nextjs", + "@clerk/shared": "file:.yalc/@clerk/shared", "@clerk/themes": "latest", - "@clerk/types": "latest", + "@clerk/types": "file:.yalc/@clerk/types", "next": "^13.5.6", "react": "^18.2.0", "react-dom": "^18.2.0" From 3b170f60fff93e2146545c85dad06e1d47ec3092 Mon Sep 17 00:00:00 2001 From: Colin Sidoti Date: Thu, 7 Dec 2023 08:55:38 -0800 Subject: [PATCH 04/19] chore(backend): Refactor authenticateRequest to clarify logic --- packages/backend/src/index.ts | 5 + packages/backend/src/tokens/authStatus.ts | 7 +- packages/backend/src/tokens/request.ts | 259 +++++++------- packages/backend/src/tokens/request2.ts | 357 +++++++++++++++++++ packages/nextjs/src/server/authMiddleware.ts | 11 +- packages/shared/src/keys.ts | 35 ++ 6 files changed, 540 insertions(+), 134 deletions(-) create mode 100644 packages/backend/src/tokens/request2.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4b1bcafe9c5..c560a0a459a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,6 +6,8 @@ import type { CreateBackendApiOptions } from './api'; import { createBackendApiClient } from './api'; import type { CreateAuthenticateRequestOptions } from './tokens'; import { createAuthenticateRequest } from './tokens'; +import type { AuthenticateRequestOptions } from './tokens/request2'; +import { baseAuthenticateRequest } from './tokens/request2'; export { createIsomorphicRequest } from './util/IsomorphicRequest'; @@ -39,6 +41,9 @@ export function createClerkClient(options: ClerkOptions) { return { ...apiClient, ...requestState, + authenticateRequest: (request: Request, requestOptions: AuthenticateRequestOptions) => { + return baseAuthenticateRequest(request, requestOptions, options); + }, telemetry, }; } diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 02b35beccc2..3571121426f 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -57,7 +57,7 @@ export type InterstitialState = Omit null; }; -export type HandshakeState = Omit & { +export type HandshakeState = Omit & { status: AuthStatus.Handshake; headers: Headers; isInterstitial: false; @@ -71,6 +71,7 @@ export type UnknownState = Omit { const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(options.request); - const ruleOptions = { + const authenticateContext = { ...options, ...loadOptionsFromHeaders(headers), ...loadOptionsFromCookies(cookies), searchParams, derivedRequestUrl, - } satisfies InterstitialRuleOptions; + }; - // TODO: Get types in a better place, there's definitely a pk here - const pk = parsePublishableKey(options.publishableKey); - if (!pk) { - throw new Error('no pk'); + const devBrowserToken = searchParams?.get('__clerk_db_jwt') || cookies('__clerk_db_jwt') || ''; + const handshakeToken = searchParams?.get('__clerk_handshake') || cookies('__clerk_handshake') || ''; + + assertValidSecretKey(authenticateContext.secretKey); + + if (authenticateContext.isSatellite) { + assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); + if (authenticateContext.signInUrl && authenticateContext.origin) { + assertSignInUrlFormatAndOrigin(authenticateContext.signInUrl, authenticateContext.origin); + } + assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); } - const instanceType = pk.instanceType; + function buildRedirectToHandshake() { + const redirectUrl = new URL(derivedRequestUrl); + redirectUrl.searchParams.delete('__clerk_db_jwt'); + const frontendApiNoProtocol = pk.frontendApi.replace(/http(s)?:\/\//, ''); + + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); + + if (pk?.instanceType === 'development' && devBrowserToken) { + url.searchParams.append('__clerk_db_jwt', devBrowserToken); + } + + return new Headers({ location: url.href }); + } + + async function resolveHandshake() { + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; - assertValidSecretKey(ruleOptions.secretKey); + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (x.startsWith('__session=')) { + sessionToken = x.split(';')[0].substring(10); + } + }); + + // Right after a handshake is a good time to detect the skew + // const detectedSkew + if (instanceType === 'development') { + const newUrl = new URL(authenticateContext.derivedRequestUrl); + newUrl.searchParams.delete('__clerk_handshake'); + newUrl.searchParams.delete('__clerk_help'); + headers.append('Location', newUrl.toString()); + } + + if (sessionToken === '') { + return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + } + + // Uncaught + const verifyResult = await verifyToken(sessionToken, authenticateContext); - if (ruleOptions.isSatellite) { - assertSignInUrlExists(ruleOptions.signInUrl, ruleOptions.secretKey); - if (ruleOptions.signInUrl && ruleOptions.origin) { - assertSignInUrlFormatAndOrigin(ruleOptions.signInUrl, ruleOptions.origin); + if (verifyResult) { + return signedIn(authenticateContext, verifyResult, headers); } - assertProxyUrlOrDomain(ruleOptions.proxyUrl || ruleOptions.domain); } + const pk = parsePublishableKeyWithOptions({ + publishableKey: options.publishableKey, + proxyUrl: options.proxyUrl, + domain: options.domain, + }); + + const instanceType = pk.instanceType; + async function authenticateRequestWithTokenInHeader() { try { - const state = await runInterstitialRules(ruleOptions, [hasValidHeaderToken]); + const state = await runInterstitialRules(authenticateContext, [hasValidHeaderToken]); return state; } catch (err) { return handleError(err, 'header'); @@ -87,127 +175,63 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } async function authenticateRequestWithTokenInCookie() { - function buildRedirectToHandshake({ - publishableKey, - devBrowserToken, - derivedRequestUrl, - proxyUrl, - domain, - }: InterstitialRuleOptions): string { - const redirectUrl = new URL(derivedRequestUrl as URL); - redirectUrl.searchParams.delete('__clerk_db_jwt'); - const pk = parsePublishableKey(publishableKey); - const pkFapi = pk?.frontendApi || ''; - // determine proper FAPI url, taking into account multi-domain setups - const frontendApi = proxyUrl || (pk?.instanceType !== 'development' ? addClerkPrefix(domain) : '') || pkFapi; - const frontendApiNoProtocol = frontendApi.replace(/http(s)?:\/\//, ''); - - const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); - url.searchParams.append('redirect_url', redirectUrl?.href || ''); - - if (pk?.instanceType === 'development' && devBrowserToken) { - url.searchParams.append('__clerk_db_jwt', devBrowserToken); - } - return url.href; - } - - const clientUat = parseInt(ruleOptions.clientUat || '', 10) || 0; + const clientUat = parseInt(authenticateContext.clientUat || '', 10) || 0; const hasActiveClient = clientUat > 0; // TODO rename this to sessionToken - const sessionToken = ruleOptions.sessionTokenInCookie; + const sessionToken = authenticateContext.sessionTokenInCookie; const hasSessionToken = !!sessionToken; - const handshakeToken = ruleOptions.handshakeToken; - // ================ This is to end the handshake if necessary =================== + /** + * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. + */ if (handshakeToken) { - const headers = new Headers({ - 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Credentials': 'true', - }); - - const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; - - let sessionToken = ''; - cookiesToSet.forEach((x: string) => { - headers.append('Set-Cookie', x); - if (x.startsWith('__session=')) { - sessionToken = x.split(';')[0].substring(10); - } - }); - - // Right after a handshake is a good time to detect the skew - // const detectedSkew - if (parsePublishableKey(options.publishableKey)?.instanceType === 'development') { - const newUrl = new URL(ruleOptions.derivedRequestUrl); - newUrl.searchParams.delete('__clerk_handshake'); - newUrl.searchParams.delete('__clerk_help'); - headers.append('Location', newUrl.toString()); - } - - if (sessionToken === '') { - return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); - } + const handshakeResult = await resolveHandshake(); - // Uncaught - const verifyResult = await verifyToken(sessionToken, ruleOptions); - - if (verifyResult) { - return signedIn(ruleOptions, verifyResult, headers); + // This shouldn't ever be undefined, but to appease the types we check truthiness before returning + if (handshakeResult) { + return handshakeResult; } } - // ================ This is to start the handshake if necessary =================== - if (instanceType === 'development' && ruleOptions.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + /** + * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. + */ + if (instanceType === 'development' && authenticateContext.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.DevBrowserSync, '', headers); } - if (ruleOptions.isSatellite && !ruleOptions.derivedRequestUrl.searchParams.has('__clerk_synced')) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + if (authenticateContext.isSatellite && !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced')) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } if (!hasActiveClient && !hasSessionToken) { - return signedOut(ruleOptions, AuthErrorReason.CookieAndUATMissing); + return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); } // This can eagerly run handshake since client_uat is SameSite=Strict in dev if (!hasActiveClient && hasSessionToken) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); } if (hasActiveClient && !hasSessionToken) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); } const decodeResult = decodeJwt(sessionToken!); if (decodeResult.payload.iat < clientUat) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, '', headers); } try { - const verifyResult = await verifyToken(sessionToken!, ruleOptions); + const verifyResult = await verifyToken(sessionToken!, authenticateContext); if (verifyResult) { - return signedIn(ruleOptions, verifyResult); + return signedIn(authenticateContext, verifyResult); } } catch (err) { if (err instanceof TokenVerificationError) { @@ -219,19 +243,16 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): ].includes(err.reason); if (reasonToHandshake) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, '', headers); } - return signedOut(ruleOptions, err.reason, err.getFullMessage()); + return signedOut(authenticateContext, err.reason, err.getFullMessage()); } - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); } - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); } function handleError(err: unknown, tokenCarrier: TokenCarrier) { @@ -245,16 +266,16 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): if (reasonToReturnInterstitial) { if (tokenCarrier === 'header') { - return unknownState(ruleOptions, err.reason, err.getFullMessage()); + return unknownState(authenticateContext, err.reason, err.getFullMessage()); } - return interstitial(ruleOptions, err.reason, err.getFullMessage()); + return interstitial(authenticateContext, err.reason, err.getFullMessage()); } - return signedOut(ruleOptions, err.reason, err.getFullMessage()); + return signedOut(authenticateContext, err.reason, err.getFullMessage()); } - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError, (err as Error).message); + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError, (err as Error).message); } - if (ruleOptions.sessionTokenInHeader) { + if (authenticateContext.sessionTokenInHeader) { return authenticateRequestWithTokenInHeader(); } return authenticateRequestWithTokenInCookie(); diff --git a/packages/backend/src/tokens/request2.ts b/packages/backend/src/tokens/request2.ts new file mode 100644 index 00000000000..dbfd892b1ae --- /dev/null +++ b/packages/backend/src/tokens/request2.ts @@ -0,0 +1,357 @@ +import { parsePublishableOptions } from '@clerk/shared/keys'; +import type { ClerkOptions, JwtPayload } from '@clerk/types'; +import { Token } from 'src'; + +import { constants } from '../constants'; +import { assertValidSecretKey } from '../util/assertValidSecretKey'; +import type { buildRequest } from '../util/IsomorphicRequest'; +import { stripAuthorizationHeader } from '../util/IsomorphicRequest'; +import { addClerkPrefix, isDevelopmentFromSecretKey, isDevOrStagingUrl } from '../util/shared'; +import { signedInAuthObject, signedOutAuthObject } from './authObjects'; +import type { AuthStatusOptionsType, RequestState } from './authStatus'; +import { AuthErrorReason, handshake, interstitial, signedOut, unknownState } from './authStatus'; +import type { TokenCarrier } from './errors'; +import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; +import type { InterstitialRuleOptions } from './interstitialRule'; +// TODO: Rename this crap, it's not interstitial anymore. +import { hasValidHeaderToken, runInterstitialRules } from './interstitialRule'; +import { decodeJwt } from './jwt'; +import { verifyToken, type VerifyTokenOptions } from './verify'; +export type OptionalVerifyTokenOptions = Partial< + Pick< + VerifyTokenOptions, + 'audience' | 'authorizedParties' | 'clockSkewInMs' | 'jwksCacheTtlInMs' | 'skipJwksCache' | 'jwtKey' + > +>; + +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`); + } +} + +function assertProxyUrlOrDomain(proxyUrlOrDomain: string | undefined) { + if (!proxyUrlOrDomain) { + throw new Error(`Missing domain and proxyUrl. A satellite application needs to specify a domain or a proxyUrl`); + } +} + +function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { + let signInUrl: URL; + try { + signInUrl = new URL(_signInUrl); + } catch { + throw new Error(`The signInUrl needs to have a absolute url format.`); + } + + if (signInUrl.origin === origin) { + throw new Error(`The signInUrl needs to be on a different origin than your satellite application.`); + } +} + +//export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions & { request: Request }; + +export enum AuthStatus { + SignedIn = 'signed-in', + SignedOut = 'signed-out', + Interstitial = 'interstitial', + Handshake = 'handshake', + Unknown = 'unknown', +} + +export async function signedIn( + options: AuthenticateRequestOptions & ClerkOptions, + token: string, + sessionClaims: JwtPayload, + headers: Headers = new Headers(), +) { + const { + publishableKey = '', + proxyUrl = '', + isSatellite = false, + domain = '', + signInUrl = '', + secretKey, + apiUrl, + apiVersion, + } = options; + + const authObject = signedInAuthObject( + sessionClaims, + { + secretKey, + apiUrl, + apiVersion, + token, + }, + { ...options, status: AuthStatus.SignedIn }, + ); + + return { + status: AuthStatus.SignedIn, + reason: null, + message: null, + proxyUrl, + publishableKey, + domain, + isSatellite, + signInUrl, + isSignedIn: true, + isInterstitial: false, + isUnknown: false, + toAuth: () => authObject, + headers, + }; +} + +export type AuthenticateRequestOptions = { + secretKey?: string; + publishableKey?: string; + domain?: string; + proxyUrl?: string; + isSatellite?: boolean | ((url: URL) => boolean); + apiUrl?: string; + apiVersion?: string; + authorizedParties?: string[]; + jwtKey?: string; + skipJwksCache?: boolean; + clockSkewInMs?: number; + jwksCacheTtlInMs: number; + audience?: string; + signInUrl?: string; +}; + +export async function baseAuthenticateRequest( + request: Request, + requestOptions: AuthenticateRequestOptions, + // clerkOptions: ClerkOptions, +): Promise { + // const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(request); + + const opts = { ...requestOptions }; + + if (!opts.publishableKey) { + throw new Error('Publishable key is missing'); + } + + const { environmentType, frontendApi } = parsePublishableOptions(opts); + + // const ruleOptions = { + // ...options, + // ...loadOptionsFromHeaders(headers), + // ...loadOptionsFromCookies(cookies), + // searchParams, + // derivedRequestUrl, + // } satisfies InterstitialRuleOptions; + + assertValidSecretKey(opts.secretKey); + + // if (opts.isSatellite) { + // assertSignInUrlExists(opts.signInUrl, opts.secretKey); + // if (opts.signInUrl && opts.origin) { + // assertSignInUrlFormatAndOrigin(opts.signInUrl, opts.origin); + // } + // assertProxyUrlOrDomain(opts.proxyUrl || opts.domain); + // } + + const headerToken = request.headers.get('Authorization')?.replace('Bearer ', ''); + if (headerToken) { + const verifyResult = await verifyToken(headerToken, opts); + if (verifyResult) { + return signedIn(opts, verifyResult, new Headers()); + } + } + + async function authenticateRequestWithTokenInCookie() { + function buildRedirectToHandshake({ + publishableKey, + devBrowserToken, + derivedRequestUrl, + proxyUrl, + domain, + }: InterstitialRuleOptions): string { + const redirectUrl = new URL(derivedRequestUrl as URL); + redirectUrl.searchParams.delete('__clerk_db_jwt'); + const pk = parsePublishableKey(publishableKey); + const pkFapi = pk?.frontendApi || ''; + // determine proper FAPI url, taking into account multi-domain setups + const frontendApi = proxyUrl || (pk?.instanceType !== 'development' ? addClerkPrefix(domain) : '') || pkFapi; + const frontendApiNoProtocol = frontendApi.replace(/http(s)?:\/\//, ''); + + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); + + if (pk?.instanceType === 'development' && devBrowserToken) { + url.searchParams.append('__clerk_db_jwt', devBrowserToken); + } + return url.href; + } + + const clientUat = parseInt(ruleOptions.clientUat || '', 10) || 0; + const hasActiveClient = clientUat > 0; + // TODO rename this to sessionToken + const sessionToken = ruleOptions.sessionTokenInCookie; + const hasSessionToken = !!sessionToken; + const handshakeToken = ruleOptions.handshakeToken; + + // ================ This is to end the handshake if necessary =================== + if (handshakeToken) { + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; + + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (x.startsWith('__session=')) { + sessionToken = x.split(';')[0].substring(10); + } + }); + + // Right after a handshake is a good time to detect the skew + // const detectedSkew + if (parsePublishableKey(options.publishableKey)?.instanceType === 'development') { + const newUrl = new URL(ruleOptions.derivedRequestUrl); + newUrl.searchParams.delete('__clerk_handshake'); + newUrl.searchParams.delete('__clerk_help'); + headers.append('Location', newUrl.toString()); + } + + if (sessionToken === '') { + return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); + } + + // Uncaught + const verifyResult = await verifyToken(sessionToken, ruleOptions); + + if (verifyResult) { + return signedIn(ruleOptions, verifyResult, headers); + } + } + + // ================ This is to start the handshake if necessary =================== + if (instanceType === 'development' && ruleOptions.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + + if (ruleOptions.isSatellite && !ruleOptions.derivedRequestUrl.searchParams.has('__clerk_synced')) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + } + + if (!hasActiveClient && !hasSessionToken) { + return signedOut(ruleOptions, AuthErrorReason.CookieAndUATMissing); + } + + // This can eagerly run handshake since client_uat is SameSite=Strict in dev + if (!hasActiveClient && hasSessionToken) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); + } + + if (hasActiveClient && !hasSessionToken) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); + } + + const decodeResult = decodeJwt(sessionToken); + + if (decodeResult.payload.iat < clientUat) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + } + + try { + const verifyResult = await verifyToken(sessionToken, ruleOptions); + if (verifyResult) { + return signedIn(ruleOptions, verifyResult); + } + } catch (err) { + if (err instanceof TokenVerificationError) { + err.tokenCarrier === 'cookie'; + + const reasonToHandshake = [ + TokenVerificationErrorReason.TokenExpired, + TokenVerificationErrorReason.TokenNotActiveYet, + ].includes(err.reason); + + if (reasonToHandshake) { + const headers = new Headers(); + headers.set('Location', buildRedirectToHandshake(ruleOptions)); + + // TODO: Add status code for redirection + return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); + } + return signedOut(ruleOptions, err.reason, err.getFullMessage()); + } + + return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); + } + + return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); + } + + if (ruleOptions.sessionTokenInHeader) { + return authenticateRequestWithTokenInHeader(); + } + return authenticateRequestWithTokenInCookie(); +} + +export const debugRequestState = (params: RequestState) => { + const { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain } = params; + return { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain }; +}; + +export type DebugRequestSate = ReturnType; + +/** + * Load authenticate request options related to headers. + */ +export const loadOptionsFromHeaders = (headers: ReturnType['headers']) => { + if (!headers) { + return {}; + } + + return { + sessionTokenInHeader: stripAuthorizationHeader(headers(constants.Headers.Authorization)), + origin: headers(constants.Headers.Origin), + host: headers(constants.Headers.Host), + forwardedHost: headers(constants.Headers.ForwardedHost), + forwardedProto: headers(constants.Headers.CloudFrontForwardedProto) || headers(constants.Headers.ForwardedProto), + referrer: headers(constants.Headers.Referrer), + userAgent: headers(constants.Headers.UserAgent), + }; +}; + +/** + * Load authenticate request options related to cookies. + */ +export const loadOptionsFromCookies = (cookies: ReturnType['cookies']) => { + if (!cookies) { + return {}; + } + + return { + sessionTokenInCookie: cookies?.(constants.Cookies.Session), + clientUat: cookies?.(constants.Cookies.ClientUat), + }; +}; diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index a575c0f72e0..01248f4d1d5 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -184,17 +184,8 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { return setHeader(beforeAuthRes, constants.Headers.AuthReason, 'redirect'); } - const devBrowserToken = - req.nextUrl.searchParams.get('__clerk_db_jwt') || req.cookies.get('__clerk_db_jwt')?.value || ''; - const handshakeToken = - req.nextUrl.searchParams.get('__clerk_handshake') || req.cookies.get('__clerk_handshake')?.value || ''; - // TODO: fix type discrepancy between WithAuthOptions and AuthenticateRequestOptions - const requestState = await authenticateRequest(req, { - ...options, - devBrowserToken, - handshakeToken, - } as AuthenticateRequestOptions); + const requestState = await authenticateRequest(req, options as AuthenticateRequestOptions); const requestStateHeaders = requestState.headers; const locationHeader = requestStateHeaders?.get('location'); diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9d8c056e3ed..9aedc8bc4b3 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -17,6 +17,41 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } +export function parsePublishableOptions({ + publishableKey, + domain, + proxyUrl, +}: { + publishableKey: string; + domain?: string; + proxyUrl?: string; +}) { + if (!isPublishableKey(publishableKey)) { + throw new Error('Publishable key not valid.'); + } + const environmentType = publishableKey.startsWith(PUBLISHABLE_KEY_LIVE_PREFIX) ? 'production' : 'development'; + + let frontendApi; + if (proxyUrl) { + frontendApi = proxyUrl; + } else if (environmentType !== 'development' && domain) { + frontendApi = `https://clerk.${domain}`; + } else { + frontendApi = isomorphicAtob(publishableKey.split('_')[2]); + + if (!frontendApi.endsWith('$')) { + throw new Error('Publishable key not valid.'); + } + + frontendApi = `https://${frontendApi.slice(0, -1)}`; + } + + return { + frontendApi, + environmentType, + }; +} + export function parsePublishableKey(key: string | undefined): PublishableKey | null { key = key || ''; From caff87fcc0805ed69998ede782e11655c44bebc3 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 7 Dec 2023 17:49:03 -0600 Subject: [PATCH 05/19] fix(backend): Fix options passing to authenticateRequest --- packages/backend/src/index.ts | 5 +- packages/backend/src/tokens/request.ts | 15 +- packages/backend/src/tokens/request2.ts | 357 ------------------ .../nextjs/src/server/authenticateRequest.ts | 3 +- 4 files changed, 6 insertions(+), 374 deletions(-) delete mode 100644 packages/backend/src/tokens/request2.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c560a0a459a..f11ef18e385 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,8 +6,7 @@ import type { CreateBackendApiOptions } from './api'; import { createBackendApiClient } from './api'; import type { CreateAuthenticateRequestOptions } from './tokens'; import { createAuthenticateRequest } from './tokens'; -import type { AuthenticateRequestOptions } from './tokens/request2'; -import { baseAuthenticateRequest } from './tokens/request2'; +import type { AuthenticateRequestOptions } from './tokens/request'; export { createIsomorphicRequest } from './util/IsomorphicRequest'; @@ -42,7 +41,7 @@ export function createClerkClient(options: ClerkOptions) { ...apiClient, ...requestState, authenticateRequest: (request: Request, requestOptions: AuthenticateRequestOptions) => { - return baseAuthenticateRequest(request, requestOptions, options); + return requestState.authenticateRequest({ ...requestOptions, request }); }, telemetry, }; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 0ea13c71267..2362d94b809 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,15 +1,13 @@ import { isPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; -import { Token } from 'src'; import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; -import { addClerkPrefix, isDevelopmentFromSecretKey, isDevOrStagingUrl } from '../util/shared'; +import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; import { AuthErrorReason, handshake, interstitial, signedIn, signedOut, unknownState } from './authStatus'; import type { TokenCarrier } from './errors'; import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; -import type { InterstitialRuleOptions } from './interstitialRule'; // TODO: Rename this crap, it's not interstitial anymore. import { hasValidHeaderToken, runInterstitialRules } from './interstitialRule'; import { decodeJwt } from './jwt'; @@ -152,9 +150,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): // Uncaught const verifyResult = await verifyToken(sessionToken, authenticateContext); - if (verifyResult) { - return signedIn(authenticateContext, verifyResult, headers); - } + return signedIn(authenticateContext, verifyResult, headers); } const pk = parsePublishableKeyWithOptions({ @@ -185,12 +181,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. */ if (handshakeToken) { - const handshakeResult = await resolveHandshake(); - - // This shouldn't ever be undefined, but to appease the types we check truthiness before returning - if (handshakeResult) { - return handshakeResult; - } + return resolveHandshake(); } /** diff --git a/packages/backend/src/tokens/request2.ts b/packages/backend/src/tokens/request2.ts deleted file mode 100644 index dbfd892b1ae..00000000000 --- a/packages/backend/src/tokens/request2.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { parsePublishableOptions } from '@clerk/shared/keys'; -import type { ClerkOptions, JwtPayload } from '@clerk/types'; -import { Token } from 'src'; - -import { constants } from '../constants'; -import { assertValidSecretKey } from '../util/assertValidSecretKey'; -import type { buildRequest } from '../util/IsomorphicRequest'; -import { stripAuthorizationHeader } from '../util/IsomorphicRequest'; -import { addClerkPrefix, isDevelopmentFromSecretKey, isDevOrStagingUrl } from '../util/shared'; -import { signedInAuthObject, signedOutAuthObject } from './authObjects'; -import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, handshake, interstitial, signedOut, unknownState } from './authStatus'; -import type { TokenCarrier } from './errors'; -import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; -import type { InterstitialRuleOptions } from './interstitialRule'; -// TODO: Rename this crap, it's not interstitial anymore. -import { hasValidHeaderToken, runInterstitialRules } from './interstitialRule'; -import { decodeJwt } from './jwt'; -import { verifyToken, type VerifyTokenOptions } from './verify'; -export type OptionalVerifyTokenOptions = Partial< - Pick< - VerifyTokenOptions, - 'audience' | 'authorizedParties' | 'clockSkewInMs' | 'jwksCacheTtlInMs' | 'skipJwksCache' | 'jwtKey' - > ->; - -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`); - } -} - -function assertProxyUrlOrDomain(proxyUrlOrDomain: string | undefined) { - if (!proxyUrlOrDomain) { - throw new Error(`Missing domain and proxyUrl. A satellite application needs to specify a domain or a proxyUrl`); - } -} - -function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { - let signInUrl: URL; - try { - signInUrl = new URL(_signInUrl); - } catch { - throw new Error(`The signInUrl needs to have a absolute url format.`); - } - - if (signInUrl.origin === origin) { - throw new Error(`The signInUrl needs to be on a different origin than your satellite application.`); - } -} - -//export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions & { request: Request }; - -export enum AuthStatus { - SignedIn = 'signed-in', - SignedOut = 'signed-out', - Interstitial = 'interstitial', - Handshake = 'handshake', - Unknown = 'unknown', -} - -export async function signedIn( - options: AuthenticateRequestOptions & ClerkOptions, - token: string, - sessionClaims: JwtPayload, - headers: Headers = new Headers(), -) { - const { - publishableKey = '', - proxyUrl = '', - isSatellite = false, - domain = '', - signInUrl = '', - secretKey, - apiUrl, - apiVersion, - } = options; - - const authObject = signedInAuthObject( - sessionClaims, - { - secretKey, - apiUrl, - apiVersion, - token, - }, - { ...options, status: AuthStatus.SignedIn }, - ); - - return { - status: AuthStatus.SignedIn, - reason: null, - message: null, - proxyUrl, - publishableKey, - domain, - isSatellite, - signInUrl, - isSignedIn: true, - isInterstitial: false, - isUnknown: false, - toAuth: () => authObject, - headers, - }; -} - -export type AuthenticateRequestOptions = { - secretKey?: string; - publishableKey?: string; - domain?: string; - proxyUrl?: string; - isSatellite?: boolean | ((url: URL) => boolean); - apiUrl?: string; - apiVersion?: string; - authorizedParties?: string[]; - jwtKey?: string; - skipJwksCache?: boolean; - clockSkewInMs?: number; - jwksCacheTtlInMs: number; - audience?: string; - signInUrl?: string; -}; - -export async function baseAuthenticateRequest( - request: Request, - requestOptions: AuthenticateRequestOptions, - // clerkOptions: ClerkOptions, -): Promise { - // const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(request); - - const opts = { ...requestOptions }; - - if (!opts.publishableKey) { - throw new Error('Publishable key is missing'); - } - - const { environmentType, frontendApi } = parsePublishableOptions(opts); - - // const ruleOptions = { - // ...options, - // ...loadOptionsFromHeaders(headers), - // ...loadOptionsFromCookies(cookies), - // searchParams, - // derivedRequestUrl, - // } satisfies InterstitialRuleOptions; - - assertValidSecretKey(opts.secretKey); - - // if (opts.isSatellite) { - // assertSignInUrlExists(opts.signInUrl, opts.secretKey); - // if (opts.signInUrl && opts.origin) { - // assertSignInUrlFormatAndOrigin(opts.signInUrl, opts.origin); - // } - // assertProxyUrlOrDomain(opts.proxyUrl || opts.domain); - // } - - const headerToken = request.headers.get('Authorization')?.replace('Bearer ', ''); - if (headerToken) { - const verifyResult = await verifyToken(headerToken, opts); - if (verifyResult) { - return signedIn(opts, verifyResult, new Headers()); - } - } - - async function authenticateRequestWithTokenInCookie() { - function buildRedirectToHandshake({ - publishableKey, - devBrowserToken, - derivedRequestUrl, - proxyUrl, - domain, - }: InterstitialRuleOptions): string { - const redirectUrl = new URL(derivedRequestUrl as URL); - redirectUrl.searchParams.delete('__clerk_db_jwt'); - const pk = parsePublishableKey(publishableKey); - const pkFapi = pk?.frontendApi || ''; - // determine proper FAPI url, taking into account multi-domain setups - const frontendApi = proxyUrl || (pk?.instanceType !== 'development' ? addClerkPrefix(domain) : '') || pkFapi; - const frontendApiNoProtocol = frontendApi.replace(/http(s)?:\/\//, ''); - - const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); - url.searchParams.append('redirect_url', redirectUrl?.href || ''); - - if (pk?.instanceType === 'development' && devBrowserToken) { - url.searchParams.append('__clerk_db_jwt', devBrowserToken); - } - return url.href; - } - - const clientUat = parseInt(ruleOptions.clientUat || '', 10) || 0; - const hasActiveClient = clientUat > 0; - // TODO rename this to sessionToken - const sessionToken = ruleOptions.sessionTokenInCookie; - const hasSessionToken = !!sessionToken; - const handshakeToken = ruleOptions.handshakeToken; - - // ================ This is to end the handshake if necessary =================== - if (handshakeToken) { - const headers = new Headers({ - 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Credentials': 'true', - }); - - const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; - - let sessionToken = ''; - cookiesToSet.forEach((x: string) => { - headers.append('Set-Cookie', x); - if (x.startsWith('__session=')) { - sessionToken = x.split(';')[0].substring(10); - } - }); - - // Right after a handshake is a good time to detect the skew - // const detectedSkew - if (parsePublishableKey(options.publishableKey)?.instanceType === 'development') { - const newUrl = new URL(ruleOptions.derivedRequestUrl); - newUrl.searchParams.delete('__clerk_handshake'); - newUrl.searchParams.delete('__clerk_help'); - headers.append('Location', newUrl.toString()); - } - - if (sessionToken === '') { - return signedOut(ruleOptions, AuthErrorReason.SessionTokenMissing, '', headers); - } - - // Uncaught - const verifyResult = await verifyToken(sessionToken, ruleOptions); - - if (verifyResult) { - return signedIn(ruleOptions, verifyResult, headers); - } - } - - // ================ This is to start the handshake if necessary =================== - if (instanceType === 'development' && ruleOptions.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); - } - - if (ruleOptions.isSatellite && !ruleOptions.derivedRequestUrl.searchParams.has('__clerk_synced')) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); - } - - if (!hasActiveClient && !hasSessionToken) { - return signedOut(ruleOptions, AuthErrorReason.CookieAndUATMissing); - } - - // This can eagerly run handshake since client_uat is SameSite=Strict in dev - if (!hasActiveClient && hasSessionToken) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenWithoutClientUAT, '', headers); - } - - if (hasActiveClient && !hasSessionToken) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.ClientUATWithoutSessionToken, '', headers); - } - - const decodeResult = decodeJwt(sessionToken); - - if (decodeResult.payload.iat < clientUat) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); - } - - try { - const verifyResult = await verifyToken(sessionToken, ruleOptions); - if (verifyResult) { - return signedIn(ruleOptions, verifyResult); - } - } catch (err) { - if (err instanceof TokenVerificationError) { - err.tokenCarrier === 'cookie'; - - const reasonToHandshake = [ - TokenVerificationErrorReason.TokenExpired, - TokenVerificationErrorReason.TokenNotActiveYet, - ].includes(err.reason); - - if (reasonToHandshake) { - const headers = new Headers(); - headers.set('Location', buildRedirectToHandshake(ruleOptions)); - - // TODO: Add status code for redirection - return handshake(ruleOptions, AuthErrorReason.SessionTokenOutdated, '', headers); - } - return signedOut(ruleOptions, err.reason, err.getFullMessage()); - } - - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); - } - - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError); - } - - if (ruleOptions.sessionTokenInHeader) { - return authenticateRequestWithTokenInHeader(); - } - return authenticateRequestWithTokenInCookie(); -} - -export const debugRequestState = (params: RequestState) => { - const { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain } = params; - return { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain }; -}; - -export type DebugRequestSate = ReturnType; - -/** - * Load authenticate request options related to headers. - */ -export const loadOptionsFromHeaders = (headers: ReturnType['headers']) => { - if (!headers) { - return {}; - } - - return { - sessionTokenInHeader: stripAuthorizationHeader(headers(constants.Headers.Authorization)), - origin: headers(constants.Headers.Origin), - host: headers(constants.Headers.Host), - forwardedHost: headers(constants.Headers.ForwardedHost), - forwardedProto: headers(constants.Headers.CloudFrontForwardedProto) || headers(constants.Headers.ForwardedProto), - referrer: headers(constants.Headers.Referrer), - userAgent: headers(constants.Headers.UserAgent), - }; -}; - -/** - * Load authenticate request options related to cookies. - */ -export const loadOptionsFromCookies = (cookies: ReturnType['cookies']) => { - if (!cookies) { - return {}; - } - - return { - sessionTokenInCookie: cookies?.(constants.Cookies.Session), - clientUat: cookies?.(constants.Cookies.ClientUat), - }; -}; diff --git a/packages/nextjs/src/server/authenticateRequest.ts b/packages/nextjs/src/server/authenticateRequest.ts index 1fa419895df..e9fa2dd8d6f 100644 --- a/packages/nextjs/src/server/authenticateRequest.ts +++ b/packages/nextjs/src/server/authenticateRequest.ts @@ -10,7 +10,7 @@ import { apiEndpointUnauthorizedNextResponse, handleMultiDomainAndProxy } from ' export const authenticateRequest = async (req: NextRequest, opts: AuthenticateRequestOptions) => { const { isSatellite, domain, signInUrl, proxyUrl } = handleMultiDomainAndProxy(req, opts); - return await clerkClient.authenticateRequest({ + return await clerkClient.authenticateRequest(req, { ...opts, secretKey: opts.secretKey || SECRET_KEY, publishableKey: opts.publishableKey || PUBLISHABLE_KEY, @@ -18,7 +18,6 @@ export const authenticateRequest = async (req: NextRequest, opts: AuthenticateRe domain, signInUrl, proxyUrl, - request: req, }); }; From bd83a61d69fcccc767d61cce862584ac34b786bb Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 7 Dec 2023 20:39:05 -0600 Subject: [PATCH 06/19] feat(backend): Add sec-fetch-dest check for satellite sync, adjust tests to support additional URLs --- handshake.test.ts | 62 +++++++++++++++++++++----- packages/backend/src/constants.ts | 1 + packages/backend/src/tokens/request.ts | 7 ++- packages/nextjs/src/server/utils.ts | 2 +- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/handshake.test.ts b/handshake.test.ts index d9add2427a2..584b6ec9139 100644 --- a/handshake.test.ts +++ b/handshake.test.ts @@ -90,7 +90,7 @@ test('Test expired session token - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, ); }); @@ -110,7 +110,7 @@ test('Test expired session token - prod', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}`, ); }); @@ -130,7 +130,7 @@ test('Test early session token - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, ); }); @@ -151,7 +151,7 @@ test('Test proxyUrl - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://example.com/clerk/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, ); }); @@ -172,7 +172,7 @@ test('Test proxyUrl - prod', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://example.com/clerk/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + `https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}`, ); }); @@ -193,7 +193,7 @@ test('Test domain - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, ); }); @@ -214,7 +214,7 @@ test('Test domain - prod', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://clerk.example.com/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}`, ); }); @@ -232,7 +232,7 @@ test('Test missing session token, positive uat - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, ); }); @@ -250,7 +250,7 @@ test('Test missing session token, positive uat - prod', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}`, ); }); @@ -313,6 +313,42 @@ test('Test missing session token, missing uat (indicating signed out) - prod', a expect(res.status).toBe(200); }); +test('Test signed out satellite no sec-fetch-dest=document - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + +test('Test signed out satellite with sec-fetch-dest=document - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(url + '/')}`, + ); +}); + test('Test missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => { const config = generateConfig({ mode: 'test', @@ -343,7 +379,9 @@ test('Test redirect url - path and qs - dev', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent( + `${url}/`, + )}hello%3Ffoo%3Dbar${devBrowserQuery}`, ); }); @@ -363,7 +401,7 @@ test('Test redirect url - path and qs - prod', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2Fhello%3Ffoo%3Dbar`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}hello%3Ffoo%3Dbar`, ); }); @@ -571,7 +609,7 @@ test('External visit - new devbrowser', async () => { }); expect(res.status).toBe(307); expect(res.headers.get('location')).toBe( - `https://${config.pkHost}/v1/client/handshake?redirect_url=http%3A%2F%2Flocalhost%3A4011%2F&__clerk_db_jwt=asdf`, + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}&__clerk_db_jwt=asdf`, ); }); diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index ce7a8d7c5c3..a0f15f5f0df 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -33,6 +33,7 @@ const Headers = { Origin: 'origin', Host: 'host', ContentType: 'content-type', + SecFetchDest: 'sec-fetch-dest', } as const; const SearchParams = { diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 2362d94b809..fd20071f328 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -192,7 +192,11 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): return handshake(authenticateContext, AuthErrorReason.DevBrowserSync, '', headers); } - if (authenticateContext.isSatellite && !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced')) { + if ( + authenticateContext.isSatellite && + authenticateContext.secFetchDest === 'document' && + !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') + ) { const headers = buildRedirectToHandshake(); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } @@ -295,6 +299,7 @@ export const loadOptionsFromHeaders = (headers: ReturnType[ forwardedProto: headers(constants.Headers.CloudFrontForwardedProto) || headers(constants.Headers.ForwardedProto), referrer: headers(constants.Headers.Referrer), userAgent: headers(constants.Headers.UserAgent), + secFetchDest: headers(constants.Headers.SecFetchDest), }; }; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 6735ea023ed..d88a46b2e57 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -239,7 +239,7 @@ export const handleMultiDomainAndProxy = (req: NextRequest, opts: AuthenticateRe throw new Error(missingDomainAndProxy); } - if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(SECRET_KEY)) { + if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY)) { throw new Error(missingSignInUrlInDev); } From dbd36bf4a6e685a6a886f44e754bf313436e7f8f Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 7 Dec 2023 21:37:05 -0600 Subject: [PATCH 07/19] feat(backend): Account for clock skew in dev, but still log error --- handshake.test.ts | 5 ++-- packages/backend/src/tokens/request.ts | 37 ++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/handshake.test.ts b/handshake.test.ts index 584b6ec9139..aec37a56fb6 100644 --- a/handshake.test.ts +++ b/handshake.test.ts @@ -531,7 +531,8 @@ test('Handshake result - dev - skew - clock behind', async () => { }), redirect: 'manual', }); - expect(res.status).toBe(500); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe('/'); }); test('Handshake result - dev - skew - clock ahead', async () => { @@ -549,7 +550,7 @@ test('Handshake result - dev - skew - clock ahead', async () => { }), redirect: 'manual', }); - expect(res.status).toBe(500); + expect(res.status).toBe(307); }); test('Handshake result - dev - mismatched keys', async () => { diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index fd20071f328..cf97d7e30fe 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,4 +1,5 @@ import { isPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; +import type { JwtPayload } from '@clerk/types'; import { constants } from '../constants'; import { assertValidSecretKey } from '../util/assertValidSecretKey'; @@ -134,8 +135,6 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } }); - // Right after a handshake is a good time to detect the skew - // const detectedSkew if (instanceType === 'development') { const newUrl = new URL(authenticateContext.derivedRequestUrl); newUrl.searchParams.delete('__clerk_handshake'); @@ -147,10 +146,37 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); } - // Uncaught - const verifyResult = await verifyToken(sessionToken, authenticateContext); + let verifyResult: JwtPayload; - return signedIn(authenticateContext, verifyResult, headers); + try { + verifyResult = await verifyToken(sessionToken, authenticateContext); + } catch (err) { + if (err instanceof TokenVerificationError) { + if ( + instanceType === 'development' && + (err.reason === TokenVerificationErrorReason.TokenExpired || + err.reason === TokenVerificationErrorReason.TokenNotActiveYet) + ) { + // This probably means we're dealing with clock skew + console.error( + `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. + +To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). + +--- + +${err.getFullMessage()}`, + ); + + // Retry with a generous clock skew allowance (1 day) + verifyResult = await verifyToken(sessionToken, { ...authenticateContext, clockSkewInMs: 86_400_000 }); + } + } else { + throw err; + } + } + + return signedIn(authenticateContext, verifyResult!, headers); } const pk = parsePublishableKeyWithOptions({ @@ -193,6 +219,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): } if ( + instanceType === 'production' && authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document' && !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') From 4f2af5a1c6f59870f4b9bddadddfdfb3d7da1cc8 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 7 Dec 2023 21:44:30 -0600 Subject: [PATCH 08/19] feat(backend): Refactor backend tests to account for recent refactoring to authenticateRequest --- handshake.test.ts | 95 ++++++++++++ packages/backend/src/tokens/authStatus.ts | 3 +- packages/backend/src/tokens/factory.test.ts | 10 +- .../backend/src/tokens/jwt/assertions.test.ts | 1 - .../backend/src/tokens/jwt/signJwt.test.ts | 4 +- packages/backend/src/tokens/keys.test.ts | 42 ------ packages/backend/src/tokens/keys.ts | 17 --- packages/backend/src/tokens/request.test.ts | 141 ++++-------------- packages/backend/src/tokens/request.ts | 86 +++++++---- packages/backend/src/tokens/verify.test.ts | 41 ----- packages/shared/src/keys.ts | 35 ----- playground/nextjs/middleware.ts | 2 + 12 files changed, 190 insertions(+), 287 deletions(-) diff --git a/handshake.test.ts b/handshake.test.ts index aec37a56fb6..76c32a191e7 100644 --- a/handshake.test.ts +++ b/handshake.test.ts @@ -57,6 +57,24 @@ test('Test standard signed-in - dev', async () => { expect(res.status).toBe(200); }); +test('Test standard signed-in - authorization header - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + test('Test standard signed-in - prod', async () => { const config = generateConfig({ mode: 'live', @@ -74,6 +92,24 @@ test('Test standard signed-in - prod', async () => { expect(res.status).toBe(200); }); +test('Test standard signed-in - authorization header - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'active' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + test('Test expired session token - dev', async () => { const config = generateConfig({ mode: 'test', @@ -114,6 +150,27 @@ test('Test expired session token - prod', async () => { ); }); +test('Test expired session token - authorization header - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const { token, claims } = config.generateToken({ state: 'expired' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `__client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}`, + ); +}); + test('Test early session token - dev', async () => { const config = generateConfig({ mode: 'test', @@ -134,6 +191,27 @@ test('Test early session token - dev', async () => { ); }); +test('Test early session token - authorization header - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const { token, claims } = config.generateToken({ state: 'early' }); + const clientUat = claims.iat; + const res = await fetch(url + '/', { + headers: new Headers({ + Cookie: `${devBrowserCookie} __client_uat=${clientUat};`, + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + Authorization: `Bearer ${token}`, + }), + redirect: 'manual', + }); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe( + `https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${url}/`)}${devBrowserQuery}`, + ); +}); + test('Test proxyUrl - dev', async () => { const config = generateConfig({ mode: 'test', @@ -349,6 +427,23 @@ test('Test signed out satellite with sec-fetch-dest=document - prod', async () = ); }); +test('Test signed out satellite - dev', async () => { + const config = generateConfig({ + mode: 'test', + }); + const res = await fetch(url + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'X-Sign-In-Url': 'https://example.com/sign-in', + }), + redirect: 'manual', + }); + expect(res.status).toBe(200); +}); + test('Test missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => { const config = generateConfig({ mode: 'test', diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 3571121426f..5da3fe83d93 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -11,6 +11,7 @@ export enum AuthStatus { Interstitial = 'interstitial', Handshake = 'handshake', Unknown = 'unknown', + SessionTokenOutdated = 'SessionTokenOutdated', } export type SignedInState = { @@ -106,7 +107,7 @@ type LoadResourcesOptions = { }; type RequestStateParams = { - publishableKey: string; + publishableKey?: string; domain?: string; isSatellite?: boolean; proxyUrl?: string; diff --git a/packages/backend/src/tokens/factory.test.ts b/packages/backend/src/tokens/factory.test.ts index ced8f12a71d..7b3b0dd03d7 100644 --- a/packages/backend/src/tokens/factory.test.ts +++ b/packages/backend/src/tokens/factory.test.ts @@ -3,6 +3,8 @@ import type QUnit from 'qunit'; import type { ApiClient } from '../api'; import { createAuthenticateRequest } from './factory'; +const TEST_PK = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; + export default (QUnit: QUnit) => { const { module, test } = QUnit; @@ -19,7 +21,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', @@ -41,7 +43,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', @@ -54,7 +56,7 @@ export default (QUnit: QUnit) => { const overrides = { secretKey: 'r-sk', - publishableKey: 'r-pk', + publishableKey: TEST_PK, }; const requestState = await authenticateRequest({ request: new Request('http://example.com/'), @@ -73,7 +75,7 @@ export default (QUnit: QUnit) => { apiUrl: 'apiUrl', apiVersion: 'apiVersion', proxyUrl: 'proxyUrl', - publishableKey: 'pk', + publishableKey: TEST_PK, isSatellite: false, domain: 'domain', audience: 'domain', diff --git a/packages/backend/src/tokens/jwt/assertions.test.ts b/packages/backend/src/tokens/jwt/assertions.test.ts index 69a0c5be57d..97296209867 100644 --- a/packages/backend/src/tokens/jwt/assertions.test.ts +++ b/packages/backend/src/tokens/jwt/assertions.test.ts @@ -9,7 +9,6 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, - assertIssuerClaim, assertSubClaim, } from './assertions'; diff --git a/packages/backend/src/tokens/jwt/signJwt.test.ts b/packages/backend/src/tokens/jwt/signJwt.test.ts index 5e97ec823cc..36c7db01f7e 100644 --- a/packages/backend/src/tokens/jwt/signJwt.test.ts +++ b/packages/backend/src/tokens/jwt/signJwt.test.ts @@ -31,7 +31,7 @@ export default (QUnit: QUnit) => { header: mockJwtHeader, }); - const verifiedPayload = await verifyJwt(jwt, { key: publicJwks, issuer: mockJwtPayload.iss }); + const verifiedPayload = await verifyJwt(jwt, { key: publicJwks }); assert.deepEqual(verifiedPayload, payload); }); @@ -41,7 +41,7 @@ export default (QUnit: QUnit) => { header: mockJwtHeader, }); - const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey, issuer: mockJwtPayload.iss }); + const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey }); assert.deepEqual(verifiedPayload, payload); }); }); diff --git a/packages/backend/src/tokens/keys.test.ts b/packages/backend/src/tokens/keys.test.ts index bde80cc2d1f..682d95d7cf1 100644 --- a/packages/backend/src/tokens/keys.test.ts +++ b/packages/backend/src/tokens/keys.test.ts @@ -65,24 +65,6 @@ export default (QUnit: QUnit) => { assert.propEqual(jwk, mockRsaJwk); }); - test('loads JWKS from Frontend API when issuer is provided', async assert => { - fakeFetch.onCall(0).returns(jsonOk(mockJwks)); - const jwk = await loadClerkJWKFromRemote({ - issuer: 'https://clerk.inspired.puma-74.lcl.dev', - kid: mockRsaJwkKid, - skipJwksCache: true, - }); - - fakeFetch.calledOnceWith('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', - }, - }); - assert.propEqual(jwk, mockRsaJwk); - }); - test('loads JWKS from Backend API using the provided apiUrl', async assert => { fakeFetch.onCall(0).returns(jsonOk(mockJwks)); const jwk = await loadClerkJWKFromRemote({ @@ -169,30 +151,6 @@ export default (QUnit: QUnit) => { } }); - test('throws an error when JWKS can not be fetched from Backend or Frontend API and cache updated less than 5 minutes ago', async assert => { - const kid = 'ins_whatever'; - try { - await loadClerkJWKFromRemote({ - secretKey: 'deadbeef', - kid, - }); - assert.false(true); - } catch (err) { - if (err instanceof Error) { - assert.propEqual(err, { - reason: 'jwk-remote-missing', - action: 'Contact support@clerk.com', - }); - assert.propContains(err, { - message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: ${mockRsaJwkKid}, local`, - }); - } else { - // This should never be reached. If it does, the suite should fail - assert.false(true); - } - } - }); - test('throws an error when no JWK matches the provided kid', async assert => { fakeFetch.onCall(0).returns(jsonOk(mockJwks)); const kid = 'ins_whatever'; diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 972d10fad12..f6de7713b23 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -161,23 +161,6 @@ export async function loadClerkJWKFromRemote({ return jwk; } -async function fetchJWKSFromFAPI(issuer: string) { - const url = new URL(issuer); - url.pathname = joinPaths(url.pathname, '.well-known/jwks.json'); - - const response = await runtime.fetch(url.href); - - if (!response.ok) { - throw new TokenVerificationError({ - action: TokenVerificationErrorAction.ContactSupport, - message: `Error loading Clerk JWKS from ${url.href} with code=${response.status}`, - reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, - }); - } - - return response.json(); -} - async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string) { if (!key) { throw new TokenVerificationError({ diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index f2b24dc5ff2..d59456aafda 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -20,7 +20,7 @@ function assertSignedOut( message?: string; }, ) { - assert.propEqual(requestState, { + assert.propContains(requestState, { publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', proxyUrl: '', status: AuthStatus.SignedOut, @@ -54,7 +54,7 @@ function assertSignedOutToAuth(assert, requestState: RequestState) { }); } -function assertInterstitial( +function assertHandshake( assert, requestState: RequestState, expectedState: { @@ -67,9 +67,8 @@ function assertInterstitial( assert.propContains(requestState, { publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', proxyUrl: '', - status: AuthStatus.Interstitial, + status: AuthStatus.Handshake, isSignedIn: false, - isInterstitial: true, isUnknown: false, isSatellite: false, signInUrl: '', @@ -259,13 +258,13 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('headerToken: returns interstitial state when token expired [1y.2n]', async assert => { + test('headerToken: returns handshake state when token expired [1y.2n]', async assert => { // advance clock for 1 hour fakeClock.tick(3600 * 1000); const requestState = await authenticateRequest(defaultMockHeaderAuthOptions()); - assertUnknown(assert, requestState, TokenVerificationErrorReason.TokenExpired); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.strictEqual(requestState.toAuth(), null); }); @@ -302,50 +301,12 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - // - // HTTP Authorization does NOT exist and __session cookie exists - // - - test('cookieToken: returns signed out state when cross-origin request [2y]', async assert => { - const requestState = await authenticateRequest( - defaultMockCookieAuthOptions( - { - ...defaultHeaders, - origin: 'https://clerk.com', - 'x-forwarded-proto': 'http', - }, - { __session: mockJwt }, - ), - ); - - assertSignedOut(assert, requestState, { - reason: AuthErrorReason.HeaderMissingCORS, - }); - assertSignedOutToAuth(assert, requestState); - }); - - test('cookieToken: returns signed out when non browser requests in development [3y]', async assert => { - const nonBrowserUserAgent = 'curl'; - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( - { - ...defaultHeaders, - 'user-agent': nonBrowserUserAgent, - }, - { __client_uat: '12345', __session: mockJwt }, - ), - secretKey: 'test_deadbeef', - }); - - assertSignedOut(assert, requestState, { reason: AuthErrorReason.HeaderMissingNonBrowser }); - assertSignedOutToAuth(assert, requestState); - }); - test('cookieToken: returns interstitial when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { const requestState = await authenticateRequest({ ...defaultMockCookieAuthOptions( { ...defaultHeaders, + 'Sec-Fetch-Dest': 'document', }, { __client_uat: '0' }, ), @@ -356,7 +317,7 @@ export default (QUnit: QUnit) => { domain: 'satellite.dev', }); - assertInterstitial(assert, requestState, { + assertHandshake(assert, requestState, { reason: AuthErrorReason.SatelliteCookieNeedsSyncing, isSatellite: true, signInUrl: 'https://primary.dev/sign-in', @@ -382,7 +343,7 @@ export default (QUnit: QUnit) => { }); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + reason: AuthErrorReason.CookieAndUATMissing, isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', @@ -392,15 +353,19 @@ export default (QUnit: QUnit) => { test('cookieToken: returns interstitial when app is satellite, returns from primary and is dev instance [13y]', async assert => { const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(), + ...defaultMockCookieAuthOptions( + undefined, + undefined, + `http://satellite.example/path?__clerk_synced=true&__clerk_db_jwt=${mockJwt}`, + ), secretKey: 'sk_test_deadbeef', signInUrl: 'http://primary.example/sign-in', isSatellite: true, domain: 'satellite.example', }); - assertInterstitial(assert, requestState, { - reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + assertHandshake(assert, requestState, { + reason: AuthErrorReason.DevBrowserSync, isSatellite: true, domain: 'satellite.example', signInUrl: 'http://primary.example/sign-in', @@ -411,15 +376,19 @@ export default (QUnit: QUnit) => { test('cookieToken: returns interstitial when app is not satellite and responds to syncing on dev instances[12y]', async assert => { const sp = new URLSearchParams(); - sp.set('__clerk_satellite_url', 'http://localhost:3000'); + sp.set('__clerk_redirect_url', 'http://localhost:3000'); const requestUrl = `http://clerk.com/path?${sp.toString()}`; const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345', __session: mockJwt }, requestUrl), + ...defaultMockCookieAuthOptions( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '12345', __session: mockJwt }, + requestUrl, + ), secretKey: 'sk_test_deadbeef', isSatellite: false, }); - assertInterstitial(assert, requestState, { + assertHandshake(assert, requestState, { reason: AuthErrorReason.PrimaryRespondsToSyncing, }); assert.equal(requestState.message, ''); @@ -444,38 +413,7 @@ export default (QUnit: QUnit) => { secretKey: 'test_deadbeef', }); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieUATMissing }); - assert.equal(requestState.message, ''); - assert.strictEqual(requestState.toAuth(), null); - }); - - // Omit because it caused view-source to always returns the interstitial in development mode (there's no referrer for view-source) - skip('cookieToken: returns interstitial when no referrer in development [6y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345', __session: mockJwt }), - secretKey: 'test_deadbeef', - }); - - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CrossOriginReferrer }); - assert.equal(requestState.message, ''); - assert.strictEqual(requestState.toAuth(), null); - }); - - test('cookieToken: returns interstitial when crossOriginReferrer in development [6y]', async assert => { - // Scenario: after auth action on Clerk-hosted UIs - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( - { - ...defaultHeaders, - // this is not a typo, it's intentional to be `referer` to match HTTP header key - referer: 'https://clerk.com', - }, - { __client_uat: '12345', __session: mockJwt }, - ), - secretKey: 'test_deadbeef', - }); - - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CrossOriginReferrer }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenWithoutClientUAT }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); @@ -505,20 +443,13 @@ export default (QUnit: QUnit) => { assertSignedInToAuth(assert, requestState); }); - // // Note: The user is definitely signed out here so this interstitial can be - // // eliminated. We can keep it if we're worried about something inspecting - // // the __session cookie manually - skip('cookieToken: returns interstitial when clientUat = 0 [7y]', assert => { - assert.true(true); - }); - test('cookieToken: returns interstitial when clientUat > 0 and no cookieToken [8y]', async assert => { const requestState = await authenticateRequest({ ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345' }), secretKey: 'deadbeef', }); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieMissing }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.ClientUATWithoutSessionToken }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); @@ -529,7 +460,7 @@ export default (QUnit: QUnit) => { }); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.StandardSignedOut, + reason: AuthErrorReason.CookieAndUATMissing, }); assertSignedOutToAuth(assert, requestState); }); @@ -542,7 +473,7 @@ export default (QUnit: QUnit) => { }), ); - assertInterstitial(assert, requestState, { reason: AuthErrorReason.CookieOutDated }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.equal(requestState.message, ''); assert.strictEqual(requestState.toAuth(), null); }); @@ -583,7 +514,7 @@ export default (QUnit: QUnit) => { // }, // ); - test('cookieToken: returns interstitial when cookieToken.iat >= clientUat and expired token [10y.2n.1n]', async assert => { + test('cookieToken: returns handshake when cookieToken.iat >= clientUat and expired token [10y.2n.1n]', async assert => { // advance clock for 1 hour fakeClock.tick(3600 * 1000); @@ -594,7 +525,7 @@ export default (QUnit: QUnit) => { }), ); - assertInterstitial(assert, requestState, { reason: TokenVerificationErrorReason.TokenExpired }); + assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.true(/^JWT is expired/.test(requestState.message || '')); assert.strictEqual(requestState.toAuth(), null); }); @@ -617,22 +548,11 @@ export default (QUnit: QUnit) => { }); module('tokens.loadOptionsFromHeaders', () => { - const defaultOptions = { - headerToken: '', - origin: '', - host: '', - forwardedHost: '', - forwardedProto: '', - referrer: '', - userAgent: '', - }; - test('returns forwarded headers from headers', assert => { const headersData = { 'x-forwarded-proto': 'http', 'x-forwarded-port': '80', 'x-forwarded-host': 'example.com' }; const headers = key => headersData[key] || ''; - assert.propEqual(loadOptionsFromHeaders(headers), { - ...defaultOptions, + assert.propContains(loadOptionsFromHeaders(headers), { forwardedProto: 'http', forwardedHost: 'example.com', }); @@ -647,8 +567,7 @@ export default (QUnit: QUnit) => { }; const headers = key => headersData[key] || ''; - assert.propEqual(loadOptionsFromHeaders(headers), { - ...defaultOptions, + assert.propContains(loadOptionsFromHeaders(headers), { forwardedProto: 'https', forwardedHost: 'example.com', }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index cf97d7e30fe..7799e18f35e 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -6,11 +6,9 @@ import { assertValidSecretKey } from '../util/assertValidSecretKey'; import { buildRequest, stripAuthorizationHeader } from '../util/IsomorphicRequest'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, handshake, interstitial, signedIn, signedOut, unknownState } from './authStatus'; +import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import type { TokenCarrier } from './errors'; import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; -// TODO: Rename this crap, it's not interstitial anymore. -import { hasValidHeaderToken, runInterstitialRules } from './interstitialRule'; import { decodeJwt } from './jwt'; import { verifyToken, type VerifyTokenOptions } from './verify'; export type OptionalVerifyTokenOptions = Partial< @@ -55,11 +53,11 @@ export function parsePublishableKeyWithOptions({ domain, proxyUrl, }: { - publishableKey: string; + publishableKey?: string; domain?: string; proxyUrl?: string; }) { - if (!isPublishableKey(publishableKey)) { + if (!publishableKey || !isPublishableKey(publishableKey)) { throw new Error('Publishable key not valid.'); } @@ -152,6 +150,7 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): verifyResult = await verifyToken(sessionToken, authenticateContext); } catch (err) { if (err instanceof TokenVerificationError) { + err.tokenCarrier = 'cookie'; if ( instanceType === 'development' && (err.reason === TokenVerificationErrorReason.TokenExpired || @@ -189,8 +188,8 @@ ${err.getFullMessage()}`, async function authenticateRequestWithTokenInHeader() { try { - const state = await runInterstitialRules(authenticateContext, [hasValidHeaderToken]); - return state; + const verifyResult = await verifyToken(authenticateContext.sessionTokenInHeader!, authenticateContext); + return await signedIn(options, verifyResult); } catch (err) { return handleError(err, 'header'); } @@ -228,10 +227,47 @@ ${err.getFullMessage()}`, return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } - if (!hasActiveClient && !hasSessionToken) { + if ( + authenticateContext.isSatellite && + authenticateContext.secFetchDest === 'document' && + !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') + ) { + // Dev initiate MD sync + + // TODO: validate signInURL exists (find the old assert function) + const redirectURL = new URL(authenticateContext.signInUrl!); + redirectURL.searchParams.append('__clerk_redirect_url', authenticateContext.derivedRequestUrl.toString()); + + const headers = new Headers({ location: redirectURL.toString() }); + return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + // if dev, and satellite, and sec-fetch-dest document, + // redirect to sign in url (primary), set redirect_url param to current url, redirect back with __clerk_db_jwt and __clerk_synced params + } + + const redirectUrl = new URL(authenticateContext.derivedRequestUrl).searchParams.get('__clerk_redirect_url'); + if (instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { + // Dev MD sync from primary, redirect back to satellite w/ __clerk_db_jwt + const redirectBackToSatelliteUrl = new URL(redirectUrl); + + if (devBrowserToken) { + redirectBackToSatelliteUrl.searchParams.append('__clerk_db_jwt', devBrowserToken); + } + redirectBackToSatelliteUrl.searchParams.append('__clerk_synced', 'true'); + + const headers = new Headers({ location: redirectBackToSatelliteUrl.toString() }); + return handshake(authenticateContext, AuthErrorReason.PrimaryRespondsToSyncing, '', headers); + } + + if (instanceType === 'development' && !hasActiveClient && !hasSessionToken) { return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); } + { + if (!hasActiveClient && !hasSessionToken) { + return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); + } + } + // This can eagerly run handshake since client_uat is SameSite=Strict in dev if (!hasActiveClient && hasSessionToken) { const headers = buildRedirectToHandshake(); @@ -256,22 +292,7 @@ ${err.getFullMessage()}`, return signedIn(authenticateContext, verifyResult); } } catch (err) { - if (err instanceof TokenVerificationError) { - err.tokenCarrier === 'cookie'; - - const reasonToHandshake = [ - TokenVerificationErrorReason.TokenExpired, - TokenVerificationErrorReason.TokenNotActiveYet, - ].includes(err.reason); - - if (reasonToHandshake) { - const headers = buildRedirectToHandshake(); - return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, '', headers); - } - return signedOut(authenticateContext, err.reason, err.getFullMessage()); - } - - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); + return handleError(err, 'cookie'); } return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); @@ -281,20 +302,19 @@ ${err.getFullMessage()}`, if (err instanceof TokenVerificationError) { err.tokenCarrier = tokenCarrier; - const reasonToReturnInterstitial = [ + const reasonToHandshake = [ TokenVerificationErrorReason.TokenExpired, TokenVerificationErrorReason.TokenNotActiveYet, ].includes(err.reason); - if (reasonToReturnInterstitial) { - if (tokenCarrier === 'header') { - return unknownState(authenticateContext, err.reason, err.getFullMessage()); - } - return interstitial(authenticateContext, err.reason, err.getFullMessage()); + if (reasonToHandshake) { + const headers = buildRedirectToHandshake(); + return handshake(authenticateContext, AuthErrorReason.SessionTokenOutdated, err.getFullMessage(), headers); } return signedOut(authenticateContext, err.reason, err.getFullMessage()); } - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError, (err as Error).message); + + return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); } if (authenticateContext.sessionTokenInHeader) { @@ -304,8 +324,8 @@ ${err.getFullMessage()}`, } export const debugRequestState = (params: RequestState) => { - const { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain } = params; - return { isSignedIn, proxyUrl, isInterstitial, reason, message, publishableKey, isSatellite, domain }; + const { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params; + return { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain }; }; export type DebugRequestSate = ReturnType; diff --git a/packages/backend/src/tokens/verify.test.ts b/packages/backend/src/tokens/verify.test.ts index a5557cffb97..c9d89e279a3 100644 --- a/packages/backend/src/tokens/verify.test.ts +++ b/packages/backend/src/tokens/verify.test.ts @@ -30,7 +30,6 @@ export default (QUnit: QUnit) => { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', skipJwksCache: true, }); @@ -42,7 +41,6 @@ export default (QUnit: QUnit) => { const payload = await verifyToken(mockJwt, { secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', skipJwksCache: true, }); @@ -56,44 +54,5 @@ export default (QUnit: QUnit) => { }); assert.propEqual(payload, mockJwtPayload); }); - - test('verifies the token by fetching the JWKs from Frontend API when issuer (string) is provided ', async assert => { - const payload = await verifyToken(mockJwt, { - authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], - issuer: 'https://clerk.inspired.puma-74.lcl.dev', - skipJwksCache: true, - }); - - fakeFetch.calledOnceWith('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Clerk-Backend-SDK': '@clerk/backend', - }, - }); - assert.propEqual(payload, mockJwtPayload); - }); - - test('throws an error when the verification fails', async assert => { - try { - await verifyToken(mockJwt, { - secretKey: 'a-valid-key', - issuer: 'https://clerk.whatever.lcl.dev', - skipJwksCache: true, - }); - // This should never be reached. If it does, the suite should fail - assert.false(true); - } catch (err) { - if (err instanceof Error) { - assert.equal( - err.message, - 'Invalid JWT issuer claim (iss) "https://clerk.inspired.puma-74.lcl.dev". Expected "https://clerk.whatever.lcl.dev".', - ); - } else { - // This should never be reached. If it does, the suite should fail - assert.false(true); - } - } - }); }); }; diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9aedc8bc4b3..9d8c056e3ed 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -17,41 +17,6 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } -export function parsePublishableOptions({ - publishableKey, - domain, - proxyUrl, -}: { - publishableKey: string; - domain?: string; - proxyUrl?: string; -}) { - if (!isPublishableKey(publishableKey)) { - throw new Error('Publishable key not valid.'); - } - const environmentType = publishableKey.startsWith(PUBLISHABLE_KEY_LIVE_PREFIX) ? 'production' : 'development'; - - let frontendApi; - if (proxyUrl) { - frontendApi = proxyUrl; - } else if (environmentType !== 'development' && domain) { - frontendApi = `https://clerk.${domain}`; - } else { - frontendApi = isomorphicAtob(publishableKey.split('_')[2]); - - if (!frontendApi.endsWith('$')) { - throw new Error('Publishable key not valid.'); - } - - frontendApi = `https://${frontendApi.slice(0, -1)}`; - } - - return { - frontendApi, - environmentType, - }; -} - export function parsePublishableKey(key: string | undefined): PublishableKey | null { key = key || ''; diff --git a/playground/nextjs/middleware.ts b/playground/nextjs/middleware.ts index a55a8020498..1d485a96a31 100644 --- a/playground/nextjs/middleware.ts +++ b/playground/nextjs/middleware.ts @@ -10,6 +10,8 @@ export const middleware = (req, evt) => { secretKey: req.headers.get("x-secret-key"), proxyUrl: req.headers.get("x-proxy-url"), domain: req.headers.get("x-domain"), + isSatellite: req.headers.get('x-satellite') === 'true', + signInUrl: req.headers.get("x-sign-in-url"), })(req, evt) }; From dc01001a7f9070758b8dc1d099ddb4b8489c4e7f Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 11 Dec 2023 14:19:02 -0600 Subject: [PATCH 09/19] feat(backend): Treat handshake payload as a signed jwt --- handshake.test.ts | 18 ++--- handshakeTestConfigs.ts | 6 ++ packages/backend/src/tokens/handshake.ts | 69 +++++++++++++++++++ packages/backend/src/tokens/jwt/verifyJwt.ts | 2 +- packages/backend/src/tokens/request.ts | 17 +++-- packages/sdk-node/src/clerkClient.ts | 3 +- .../shared/src/__tests__/devbrowser.test.ts | 4 +- 7 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 packages/backend/src/tokens/handshake.ts diff --git a/handshake.test.ts b/handshake.test.ts index 76c32a191e7..f3c92b0542f 100644 --- a/handshake.test.ts +++ b/handshake.test.ts @@ -594,7 +594,7 @@ test('Handshake result - dev - nominal', async () => { }); const { token } = config.generateToken({ state: 'active' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/?__clerk_handshake=' + handshake, { headers: new Headers({ Cookie: `${devBrowserCookie}`, @@ -617,7 +617,7 @@ test('Handshake result - dev - skew - clock behind', async () => { }); const { token } = config.generateToken({ state: 'early' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/?__clerk_handshake=' + handshake, { headers: new Headers({ Cookie: `${devBrowserCookie}`, @@ -636,7 +636,7 @@ test('Handshake result - dev - skew - clock ahead', async () => { }); const { token } = config.generateToken({ state: 'expired' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/?__clerk_handshake=' + handshake, { headers: new Headers({ Cookie: `${devBrowserCookie}`, @@ -655,7 +655,7 @@ test('Handshake result - dev - mismatched keys', async () => { }); const { token } = config.generateToken({ state: 'active' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/?__clerk_handshake=' + handshake, { headers: new Headers({ Cookie: `${devBrowserCookie}`, @@ -674,7 +674,7 @@ test('Handshake result - dev - new devbrowser', async () => { }); const { token } = config.generateToken({ state: 'active' }); const cookiesToSet = [`__session=${token};path=/`, '__clerk_db_jwt=asdf;path=/']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/?__clerk_handshake=' + handshake, { headers: new Headers({ Cookie: `${devBrowserCookie}`, @@ -715,7 +715,7 @@ test('Handshake result - prod - nominal', async () => { }); const { token } = config.generateToken({ state: 'active' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/', { headers: new Headers({ 'X-Publishable-Key': config.pk, @@ -737,7 +737,7 @@ test('Handshake result - prod - skew - clock behind', async () => { }); const { token } = config.generateToken({ state: 'early' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/', { headers: new Headers({ 'X-Publishable-Key': config.pk, @@ -755,7 +755,7 @@ test('Handshake result - prod - skew - clock ahead', async () => { }); const { token } = config.generateToken({ state: 'expired' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/', { headers: new Headers({ 'X-Publishable-Key': config.pk, @@ -774,7 +774,7 @@ test('Handshake result - prod - mismatched keys', async () => { }); const { token } = config.generateToken({ state: 'active' }); const cookiesToSet = [`__session=${token};path=/`, 'foo=bar;path=/;domain=example.com']; - const handshake = btoa(JSON.stringify(cookiesToSet)); + const handshake = await config.generateHandshakeToken(cookiesToSet); const res = await fetch(url + '/', { headers: new Headers({ 'X-Publishable-Key': config.pk, diff --git a/handshakeTestConfigs.ts b/handshakeTestConfigs.ts index 124f7d0727b..6144c07c79e 100644 --- a/handshakeTestConfigs.ts +++ b/handshakeTestConfigs.ts @@ -133,6 +133,12 @@ export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'l pk, sk, generateToken, + generateHandshakeToken(payload: string[]) { + return jwt.sign({ handshake: payload }, rsa.private, { + algorithm: 'RS256', + header: { kid: ins_id }, + }); + }, jwks, pkHost, }); diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts new file mode 100644 index 00000000000..c0b57bfbd4f --- /dev/null +++ b/packages/backend/src/tokens/handshake.ts @@ -0,0 +1,69 @@ +import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from './errors'; +import { decodeJwt, hasValidSignature, type VerifyJwtOptions } from './jwt'; +import { assertHeaderAlgorithm, assertHeaderType } from './jwt/assertions'; +import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import type { VerifyTokenOptions } from './verify'; + +export async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { + const decoded = decodeJwt(token); + + const { header, payload } = decoded; + + // Header verifications + const { typ, alg } = header; + + assertHeaderType(typ); + assertHeaderAlgorithm(alg); + + let signatureValid: boolean; + + try { + signatureValid = await hasValidSignature(decoded, key); + } catch (err) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Error verifying handshake token. ${err}`, + }); + } + + if (!signatureValid) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: 'Handshake signature is invalid.', + }); + } + + return payload as unknown as { handshake: string[] }; +} + +/** + * Similar to our verifyToken flow for Clerk-issued JWTs, but this verification flow is for our signed handshake payload. The handshake payload requires fewer verification steps. + */ +export async function verifyHandshakeToken( + token: string, + options: VerifyTokenOptions, +): Promise<{ handshake: string[] }> { + const { secretKey, apiUrl, apiVersion, jwksCacheTtlInMs, jwtKey, skipJwksCache } = options; + + const { header } = decodeJwt(token); + const { kid } = header; + + let key; + + if (jwtKey) { + key = loadClerkJWKFromLocal(jwtKey); + } else if (secretKey) { + // Fetch JWKS from Backend API using the key + key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); + } else { + throw new TokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during handshake verification.', + reason: TokenVerificationErrorReason.JWKFailedToResolve, + }); + } + + return await verifyHandshakeJwt(token, { + key, + }); +} diff --git a/packages/backend/src/tokens/jwt/verifyJwt.ts b/packages/backend/src/tokens/jwt/verifyJwt.ts index 2fd22c53c40..a0b4d218082 100644 --- a/packages/backend/src/tokens/jwt/verifyJwt.ts +++ b/packages/backend/src/tokens/jwt/verifyJwt.ts @@ -100,7 +100,7 @@ export async function verifyJwt( assertHeaderAlgorithm(alg); // Payload verifications - const { azp, sub, aud, iss, iat, exp, nbf } = payload; + const { azp, sub, aud, iat, exp, nbf } = payload; assertSubClaim(sub); assertAudienceClaim([aud], [audience]); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 7799e18f35e..b9d27dd37cf 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -9,6 +9,7 @@ import type { AuthStatusOptionsType, RequestState } from './authStatus'; import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import type { TokenCarrier } from './errors'; import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; +import { verifyHandshakeToken } from './handshake'; import { decodeJwt } from './jwt'; import { verifyToken, type VerifyTokenOptions } from './verify'; export type OptionalVerifyTokenOptions = Partial< @@ -123,7 +124,8 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): 'Access-Control-Allow-Credentials': 'true', }); - const cookiesToSet = JSON.parse(atob(handshakeToken)) as string[]; + const handshakePayload = await verifyHandshakeToken(handshakeToken, authenticateContext); + const cookiesToSet = handshakePayload.handshake; let sessionToken = ''; cookiesToSet.forEach((x: string) => { @@ -228,20 +230,19 @@ ${err.getFullMessage()}`, } if ( + instanceType === 'development' && authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document' && !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') ) { - // Dev initiate MD sync + // initiate MD sync - // TODO: validate signInURL exists (find the old assert function) + // signInUrl exists, checked at the top of `authenticateRequest` const redirectURL = new URL(authenticateContext.signInUrl!); redirectURL.searchParams.append('__clerk_redirect_url', authenticateContext.derivedRequestUrl.toString()); const headers = new Headers({ location: redirectURL.toString() }); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); - // if dev, and satellite, and sec-fetch-dest document, - // redirect to sign in url (primary), set redirect_url param to current url, redirect back with __clerk_db_jwt and __clerk_synced params } const redirectUrl = new URL(authenticateContext.derivedRequestUrl).searchParams.get('__clerk_redirect_url'); @@ -262,10 +263,8 @@ ${err.getFullMessage()}`, return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); } - { - if (!hasActiveClient && !hasSessionToken) { - return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); - } + if (!hasActiveClient && !hasSessionToken) { + return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); } // This can eagerly run handshake since client_uat is SameSite=Strict in dev diff --git a/packages/sdk-node/src/clerkClient.ts b/packages/sdk-node/src/clerkClient.ts index d92016af8dc..657b1bb339a 100644 --- a/packages/sdk-node/src/clerkClient.ts +++ b/packages/sdk-node/src/clerkClient.ts @@ -21,8 +21,7 @@ export function Clerk(options: ClerkOptions): ExtendedClerk { const expressWithAuth = createClerkExpressWithAuth({ ...options, clerkClient }); const expressRequireAuth = createClerkExpressRequireAuth({ ...options, clerkClient }); const verifyToken = (token: string, verifyOpts?: VerifyTokenOptions) => { - const issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts'); - return _verifyToken(token, { issuer, ...options, ...verifyOpts }); + return _verifyToken(token, { ...options, ...verifyOpts }); }; return Object.assign(clerkClient, { diff --git a/packages/shared/src/__tests__/devbrowser.test.ts b/packages/shared/src/__tests__/devbrowser.test.ts index 13a24e86beb..8c5a340ba60 100644 --- a/packages/shared/src/__tests__/devbrowser.test.ts +++ b/packages/shared/src/__tests__/devbrowser.test.ts @@ -11,8 +11,8 @@ describe('setDevBrowserJWTInURL(url, jwt)', () => { ['/foo?bar=42#qux', 'deadbeef', false, '/foo?bar=42#qux__clerk_db_jwt[deadbeef]'], ['/foo#__clerk_db_jwt[deadbeef]', 'deadbeef', false, '/foo#__clerk_db_jwt[deadbeef]'], ['/foo?bar=42#qux__clerk_db_jwt[deadbeef]', 'deadbeef', false, '/foo?bar=42#qux__clerk_db_jwt[deadbeef]'], - ['/foo', 'deadbeef', true, '/foo?__dev_session=deadbeef'], - ['/foo?bar=42', 'deadbeef', true, '/foo?bar=42&__dev_session=deadbeef'], + ['/foo', 'deadbeef', true, '/foo?__clerk_db_jwt=deadbeef'], + ['/foo?bar=42', 'deadbeef', true, '/foo?bar=42&__clerk_db_jwt=deadbeef'], ]; test.each(testCases)( From ae9cbf967688abe201bcdcd81186f39a86ab4381 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 11 Dec 2023 16:33:33 -0600 Subject: [PATCH 10/19] fix(backend): Add tests and adjust logic to ensure existing tests pass --- packages/backend/src/constants.ts | 8 ++ packages/backend/src/index.ts | 3 - packages/backend/src/tokens/factory.ts | 4 +- packages/backend/src/tokens/request.ts | 86 ++++++++------------ packages/nextjs/src/server/authMiddleware.ts | 15 ---- packages/shared/src/__tests__/keys.test.ts | 22 +++++ packages/shared/src/keys.ts | 33 ++++++-- 7 files changed, 94 insertions(+), 77 deletions(-) diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index a0f15f5f0df..55bb8897239 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -15,6 +15,13 @@ const Attributes = { const Cookies = { Session: '__session', ClientUat: '__client_uat', + Handshake: '__clerk_handshake', + DevBrowser: '__clerk_db_jwt', +} as const; + +const QueryParameters = { + ClerkSynced: '__clerk_synced', + ClerkRedirectUrl: '__clerk_redirect_url', } as const; const Headers = { @@ -50,4 +57,5 @@ export const constants = { Headers, SearchParams, ContentTypes, + QueryParameters, } as const; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f11ef18e385..7b5de88c998 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -40,9 +40,6 @@ export function createClerkClient(options: ClerkOptions) { return { ...apiClient, ...requestState, - authenticateRequest: (request: Request, requestOptions: AuthenticateRequestOptions) => { - return requestState.authenticateRequest({ ...requestOptions, request }); - }, telemetry, }; } diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index f24821ad634..7d28cf8dff6 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -43,10 +43,10 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio const { apiClient } = params; const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); - const authenticateRequest = (options: RunTimeOptions) => { + const authenticateRequest = (request: Request, options: RunTimeOptions) => { const { apiUrl, apiVersion } = buildTimeOptions; const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); - return authenticateRequestOriginal({ + return authenticateRequestOriginal(request, { ...options, ...runTimeOptions, // We should add all the omitted props from options here (eg apiUrl / apiVersion) diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index b9d27dd37cf..5025c91a670 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -19,7 +19,7 @@ export type OptionalVerifyTokenOptions = Partial< > >; -export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions & { request: Request }; +export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions; function assertSignInUrlExists(signInUrl: string | undefined, key: string): asserts signInUrl is string { if (!signInUrl && isDevelopmentFromSecretKey(key)) { @@ -46,41 +46,11 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } } -/** - * Parse the provided publishable key, but adjust the returned frontendApi based on the provided domain and proxyUrl. - */ -export function parsePublishableKeyWithOptions({ - publishableKey, - domain, - proxyUrl, -}: { - publishableKey?: string; - domain?: string; - proxyUrl?: string; -}) { - if (!publishableKey || !isPublishableKey(publishableKey)) { - throw new Error('Publishable key not valid.'); - } - - const { frontendApi: frontendApiFromPK, instanceType } = parsePublishableKey(publishableKey)!; - - let frontendApi = frontendApiFromPK; - if (proxyUrl) { - frontendApi = proxyUrl; - } else if (instanceType !== 'development' && domain) { - frontendApi = `https://clerk.${domain}`; - } else { - frontendApi = `https://${frontendApi}`; - } - - return { - frontendApi, - instanceType, - }; -} - -export async function authenticateRequest(options: AuthenticateRequestOptions): Promise { - const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(options.request); +export async function authenticateRequest( + request: Request, + options: AuthenticateRequestOptions, +): Promise { + const { cookies, headers, searchParams, derivedRequestUrl } = buildRequest(request); const authenticateContext = { ...options, @@ -90,8 +60,9 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): derivedRequestUrl, }; - const devBrowserToken = searchParams?.get('__clerk_db_jwt') || cookies('__clerk_db_jwt') || ''; - const handshakeToken = searchParams?.get('__clerk_handshake') || cookies('__clerk_handshake') || ''; + const devBrowserToken = + searchParams?.get(constants.Cookies.DevBrowser) || cookies(constants.Cookies.DevBrowser) || ''; + const handshakeToken = searchParams?.get(constants.Cookies.Handshake) || cookies(constants.Cookies.Handshake) || ''; assertValidSecretKey(authenticateContext.secretKey); @@ -180,8 +151,8 @@ ${err.getFullMessage()}`, return signedIn(authenticateContext, verifyResult!, headers); } - const pk = parsePublishableKeyWithOptions({ - publishableKey: options.publishableKey, + const pk = parsePublishableKey(options.publishableKey, { + fatal: true, proxyUrl: options.proxyUrl, domain: options.domain, }); @@ -200,7 +171,6 @@ ${err.getFullMessage()}`, async function authenticateRequestWithTokenInCookie() { const clientUat = parseInt(authenticateContext.clientUat || '', 10) || 0; const hasActiveClient = clientUat > 0; - // TODO rename this to sessionToken const sessionToken = authenticateContext.sessionTokenInCookie; const hasSessionToken = !!sessionToken; @@ -214,54 +184,66 @@ ${err.getFullMessage()}`, /** * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. */ - if (instanceType === 'development' && authenticateContext.derivedRequestUrl.searchParams.has('__clerk_db_jwt')) { + if ( + instanceType === 'development' && + authenticateContext.derivedRequestUrl.searchParams.has(constants.Cookies.DevBrowser) + ) { const headers = buildRedirectToHandshake(); return handshake(authenticateContext, AuthErrorReason.DevBrowserSync, '', headers); } + /** + * Begin multi-domain sync flows + */ if ( instanceType === 'production' && authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document' && - !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') + !authenticateContext.derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced) ) { const headers = buildRedirectToHandshake(); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } + // Multi-domain development sync flow if ( instanceType === 'development' && authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document' && - !authenticateContext.derivedRequestUrl.searchParams.has('__clerk_synced') + !authenticateContext.derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced) ) { // initiate MD sync // signInUrl exists, checked at the top of `authenticateRequest` const redirectURL = new URL(authenticateContext.signInUrl!); - redirectURL.searchParams.append('__clerk_redirect_url', authenticateContext.derivedRequestUrl.toString()); + redirectURL.searchParams.append( + constants.QueryParameters.ClerkRedirectUrl, + authenticateContext.derivedRequestUrl.toString(), + ); const headers = new Headers({ location: redirectURL.toString() }); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } - const redirectUrl = new URL(authenticateContext.derivedRequestUrl).searchParams.get('__clerk_redirect_url'); + // Multi-domain development sync flow + const redirectUrl = new URL(authenticateContext.derivedRequestUrl).searchParams.get( + constants.QueryParameters.ClerkRedirectUrl, + ); if (instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { // Dev MD sync from primary, redirect back to satellite w/ __clerk_db_jwt const redirectBackToSatelliteUrl = new URL(redirectUrl); if (devBrowserToken) { - redirectBackToSatelliteUrl.searchParams.append('__clerk_db_jwt', devBrowserToken); + redirectBackToSatelliteUrl.searchParams.append(constants.Cookies.DevBrowser, devBrowserToken); } - redirectBackToSatelliteUrl.searchParams.append('__clerk_synced', 'true'); + redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.ClerkSynced, 'true'); const headers = new Headers({ location: redirectBackToSatelliteUrl.toString() }); return handshake(authenticateContext, AuthErrorReason.PrimaryRespondsToSyncing, '', headers); } - - if (instanceType === 'development' && !hasActiveClient && !hasSessionToken) { - return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); - } + /** + * End multi-domain sync flows + */ if (!hasActiveClient && !hasSessionToken) { return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 01248f4d1d5..6d24c32044e 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -204,21 +204,6 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { throw new Error('Unexpected handshake or unknown state without redirect'); } - // if (requestState.isUnknown) { - // logger.debug('authenticateRequest state is unknown', requestState); - // return handleUnknownState(requestState); - // } else if (requestState.isInterstitial && isApiRoute(req)) { - // logger.debug('authenticateRequest state is interstitial in an API route', requestState); - // return handleUnknownState(requestState); - // } else if (requestState.isInterstitial) { - // logger.debug('authenticateRequest state is interstitial', requestState); - - // assertClockSkew(requestState, options); - - // const res = handleInterstitialState(requestState, options); - // return assertInfiniteRedirectionLoop(req, res, options, requestState); - // } - const auth = Object.assign(requestState.toAuth(), { isPublicRoute: isPublicRoute(req), isApiRoute: isApiRoute(req), diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 598a7c0a0d1..dcc3226c259 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -36,6 +36,10 @@ describe('parsePublishableKey(key)', () => { 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', { instanceType: 'development', frontendApi: 'foo-bar-13.clerk.accounts.dev' }, ], + [ + 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', + { instanceType: 'development', frontendApi: 'foo-bar-13.clerk.accounts.dev' }, + ], ]; // @ts-ignore @@ -44,6 +48,24 @@ describe('parsePublishableKey(key)', () => { const result = parsePublishableKey(publishableKeyStr); expect(result).toEqual(expectedPublishableKey); }); + + it('throws an error if the key is not a valid publishable key, when fatal: true', () => { + expect(() => parsePublishableKey('fake_pk', { fatal: true })).toThrowError('Publishable key not valid.'); + }); + + it('applies the proxyUrl if provided', () => { + expect(parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { proxyUrl: 'example.com/__clerk' })).toEqual({ + frontendApi: 'example.com/__clerk', + instanceType: 'production', + }); + }); + + it('applies the domain if provided for production keys', () => { + expect(parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { domain: 'example.com' })).toEqual({ + frontendApi: 'clerk.example.com', + instanceType: 'production', + }); + }); }); describe('isPublishableKey(key)', () => { diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9d8c056e3ed..238b512132b 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -4,6 +4,12 @@ import { DEV_OR_STAGING_SUFFIXES } from './constants'; import { isomorphicAtob } from './isomorphicAtob'; import { isomorphicBtoa } from './isomorphicBtoa'; +type ParsePublishableKeyOptions = { + fatal?: boolean; + domain?: string; + proxyUrl?: string; +}; + const PUBLISHABLE_KEY_LIVE_PREFIX = 'pk_live_'; const PUBLISHABLE_KEY_TEST_PREFIX = 'pk_test_'; @@ -17,10 +23,24 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } -export function parsePublishableKey(key: string | undefined): PublishableKey | null { +export function parsePublishableKey( + key: string | undefined, + options: ParsePublishableKeyOptions & { fatal: true }, +): PublishableKey; +export function parsePublishableKey( + key: string | undefined, + options?: ParsePublishableKeyOptions, +): PublishableKey | null; +export function parsePublishableKey( + key: string | undefined, + options: { fatal?: boolean; domain?: string; proxyUrl?: string } = {}, +): PublishableKey | null { key = key || ''; - if (!isPublishableKey(key)) { + if (!key || !isPublishableKey(key)) { + if (options.fatal) { + throw new Error('Publishable key not valid.'); + } return null; } @@ -28,12 +48,15 @@ export function parsePublishableKey(key: string | undefined): PublishableKey | n let frontendApi = isomorphicAtob(key.split('_')[2]); - if (!frontendApi.endsWith('$')) { - return null; - } // TODO(@dimkl): validate packages/clerk-js/src/utils/instance.ts frontendApi = frontendApi.slice(0, -1); + if (options.proxyUrl) { + frontendApi = options.proxyUrl; + } else if (instanceType !== 'development' && options.domain) { + frontendApi = `clerk.${options.domain}`; + } + return { instanceType, frontendApi, From 9f1c428f4f3be3f6c3c19a06ab2dd0a0bf8e1e7a Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 11 Dec 2023 21:07:42 -0600 Subject: [PATCH 11/19] chore(backend): Refactor tests to conform to new method signature --- packages/backend/src/tokens/factory.test.ts | 8 +- packages/backend/src/tokens/factory.ts | 2 +- packages/backend/src/tokens/request.test.ts | 239 +++++++++++--------- 3 files changed, 130 insertions(+), 119 deletions(-) diff --git a/packages/backend/src/tokens/factory.test.ts b/packages/backend/src/tokens/factory.test.ts index 7b3b0dd03d7..91831161076 100644 --- a/packages/backend/src/tokens/factory.test.ts +++ b/packages/backend/src/tokens/factory.test.ts @@ -32,7 +32,7 @@ export default (QUnit: QUnit) => { apiClient: {} as ApiClient, }); - const requestState = await authenticateRequest({ request: new Request('http://example.com/') }); + const requestState = await authenticateRequest(new Request('http://example.com/')); assert.propContains(requestState.toAuth()?.debug(), buildTimeOptions); }); @@ -58,8 +58,7 @@ export default (QUnit: QUnit) => { secretKey: 'r-sk', publishableKey: TEST_PK, }; - const requestState = await authenticateRequest({ - request: new Request('http://example.com/'), + const requestState = await authenticateRequest(new Request('http://example.com/'), { ...overrides, }); assert.propContains(requestState.toAuth()?.debug(), { @@ -86,8 +85,7 @@ export default (QUnit: QUnit) => { apiClient: {} as ApiClient, }); - const requestState = await authenticateRequest({ - request: new Request('http://example.com/'), + const requestState = await authenticateRequest(new Request('http://example.com/'), { // @ts-expect-error is used to check runtime code apiUrl: 'r-apiUrl', apiVersion: 'r-apiVersion', diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index 7d28cf8dff6..f9de398e97b 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -43,7 +43,7 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio const { apiClient } = params; const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); - const authenticateRequest = (request: Request, options: RunTimeOptions) => { + const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => { const { apiUrl, apiVersion } = buildTimeOptions; const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); return authenticateRequestOriginal(request, { diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index d59456aafda..d4658e7f5b0 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -148,8 +148,12 @@ export default (QUnit: QUnit) => { 'user-agent': 'Mozilla/TestAgent', }; + const mockRequest = (headers = {}, requestUrl = 'http://clerk.com/path') => { + return new Request(requestUrl, { headers: { ...defaultHeaders, ...headers } }); + }; + /* An otherwise bare state on a request. */ - const defaultMockAuthenticateRequestOptions = (headers = defaultHeaders, requestUrl = 'http://clerk.com/path') => + const mockOptions = (options?) => ({ secretKey: 'deadbeef', apiUrl: 'https://api.clerk.test', @@ -163,34 +167,19 @@ export default (QUnit: QUnit) => { afterSignInUrl: '', afterSignUpUrl: '', domain: '', - request: new Request(requestUrl, { headers }), + ...options, } satisfies AuthenticateRequestOptions); - const defaultMockHeaderAuthOptions = (headers = defaultHeaders, requestUrl?) => { - return { - ...defaultMockAuthenticateRequestOptions( - { - authorization: mockJwt, - ...headers, - }, - requestUrl, - ), - }; + const mockRequestWithHeaderAuth = (headers?, requestUrl?) => { + return mockRequest({ authorization: mockJwt, ...headers }, requestUrl); }; - const defaultMockCookieAuthOptions = (headers = defaultHeaders, cookies = {}, requestUrl?) => { + const mockRequestWithCookies = (headers?, cookies = {}, requestUrl?) => { const cookieStr = Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join(';'); - return { - ...defaultMockAuthenticateRequestOptions( - { - cookie: cookieStr, - ...headers, - }, - requestUrl, - ), - }; + + return mockRequest({ cookie: cookieStr, ...headers }, requestUrl); }; module('tokens.authenticateRequest(options)', hooks => { @@ -215,10 +204,12 @@ export default (QUnit: QUnit) => { test('returns signed out state if jwk fails to load from remote', async assert => { fakeFetch.onCall(0).returns(jsonOk({})); - const requestState = await authenticateRequest({ - ...defaultMockHeaderAuthOptions(), - skipJwksCache: false, - }); + const requestState = await authenticateRequest( + mockRequestWithHeaderAuth(), + mockOptions({ + skipJwksCache: false, + }), + ); const errMessage = '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)'; @@ -230,7 +221,7 @@ export default (QUnit: QUnit) => { }); test('headerToken: returns signed in state when a valid token [1y.2y]', async assert => { - const requestState = await authenticateRequest(defaultMockHeaderAuthOptions()); + const requestState = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions()); assertSignedIn(assert, requestState); assertSignedInToAuth(assert, requestState); @@ -244,10 +235,12 @@ export default (QUnit: QUnit) => { // ); test('headerToken: returns signed out state when a token with invalid authorizedParties [1y.2n]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockHeaderAuthOptions(), - authorizedParties: ['whatever'], - }); + const requestState = await authenticateRequest( + mockRequestWithHeaderAuth(), + mockOptions({ + authorizedParties: ['whatever'], + }), + ); const errMessage = 'Invalid JWT Authorized party claim (azp) "https://accounts.inspired.puma-74.lcl.dev". Expected "whatever". (reason=token-invalid-authorized-parties, token-carrier=header)'; @@ -262,7 +255,7 @@ export default (QUnit: QUnit) => { // advance clock for 1 hour fakeClock.tick(3600 * 1000); - const requestState = await authenticateRequest(defaultMockHeaderAuthOptions()); + const requestState = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions()); assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); assert.strictEqual(requestState.toAuth(), null); @@ -270,10 +263,10 @@ export default (QUnit: QUnit) => { test('headerToken: returns signed out state when invalid signature [1y.2n]', async assert => { const requestState = await authenticateRequest( - defaultMockHeaderAuthOptions({ - ...defaultHeaders, + mockRequestWithHeaderAuth({ authorization: mockInvalidSignatureJwt, }), + mockOptions(), ); const errMessage = 'JWT signature is invalid. (reason=token-invalid-signature, token-carrier=header)'; @@ -286,10 +279,8 @@ export default (QUnit: QUnit) => { test('headerToken: returns signed out state when an malformed token [1y.1n]', async assert => { const requestState = await authenticateRequest( - defaultMockHeaderAuthOptions({ - ...defaultHeaders, - authorization: 'test_header_token', - }), + mockRequestWithHeaderAuth({ authorization: 'test_header_token' }), + mockOptions(), ); const errMessage = @@ -302,20 +293,23 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns interstitial when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { - ...defaultHeaders, 'Sec-Fetch-Dest': 'document', }, - { __client_uat: '0' }, + { + __client_uat: '0', + }, ), - secretKey: 'deadbeef', - clientUat: '0', - isSatellite: true, - signInUrl: 'https://primary.dev/sign-in', - domain: 'satellite.dev', - }); + mockOptions({ + secretKey: 'deadbeef', + clientUat: '0', + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); assertHandshake(assert, requestState, { reason: AuthErrorReason.SatelliteCookieNeedsSyncing, @@ -328,19 +322,21 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed out is satellite but a non-browser request [11y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, 'user-agent': '[some-agent]', }, { __client_uat: '0' }, ), - secretKey: 'deadbeef', - isSatellite: true, - signInUrl: 'https://primary.dev/sign-in', - domain: 'satellite.dev', - }); + mockOptions({ + secretKey: 'deadbeef', + isSatellite: true, + signInUrl: 'https://primary.dev/sign-in', + domain: 'satellite.dev', + }), + ); assertSignedOut(assert, requestState, { reason: AuthErrorReason.CookieAndUATMissing, @@ -352,17 +348,15 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns interstitial when app is satellite, returns from primary and is dev instance [13y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( - undefined, - undefined, - `http://satellite.example/path?__clerk_synced=true&__clerk_db_jwt=${mockJwt}`, - ), - secretKey: 'sk_test_deadbeef', - signInUrl: 'http://primary.example/sign-in', - isSatellite: true, - domain: 'satellite.example', - }); + const requestState = await authenticateRequest( + mockRequestWithCookies({}, {}, `http://satellite.example/path?__clerk_synced=true&__clerk_db_jwt=${mockJwt}`), + mockOptions({ + secretKey: 'sk_test_deadbeef', + signInUrl: 'http://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + }), + ); assertHandshake(assert, requestState, { reason: AuthErrorReason.DevBrowserSync, @@ -378,15 +372,14 @@ export default (QUnit: QUnit) => { const sp = new URLSearchParams(); sp.set('__clerk_redirect_url', 'http://localhost:3000'); const requestUrl = `http://clerk.com/path?${sp.toString()}`; - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, 'sec-fetch-dest': 'document' }, { __client_uat: '12345', __session: mockJwt }, requestUrl, ), - secretKey: 'sk_test_deadbeef', - isSatellite: false, - }); + mockOptions({ secretKey: 'sk_test_deadbeef', isSatellite: false }), + ); assertHandshake(assert, requestState, { reason: AuthErrorReason.PrimaryRespondsToSyncing, @@ -396,10 +389,12 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed out when no cookieToken and no clientUat in production [4y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(), - secretKey: 'live_deadbeef', - }); + const requestState = await authenticateRequest( + mockRequestWithCookies(), + mockOptions({ + secretKey: 'live_deadbeef', + }), + ); assertSignedOut(assert, requestState, { reason: AuthErrorReason.CookieAndUATMissing, @@ -408,10 +403,12 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns interstitial when no clientUat in development [5y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __session: mockJwt }), - secretKey: 'test_deadbeef', - }); + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __session: mockJwt }), + mockOptions({ + secretKey: 'test_deadbeef', + }), + ); assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenWithoutClientUAT }); assert.equal(requestState.message, ''); @@ -420,8 +417,8 @@ export default (QUnit: QUnit) => { test('cookieToken: returns undefined when crossOriginReferrer in development and is satellite [6n]', async assert => { // Scenario: after auth action on Clerk-hosted UIs - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, // this is not a typo, it's intentional to be `referer` to match HTTP header key @@ -429,11 +426,13 @@ export default (QUnit: QUnit) => { }, { __client_uat: '12345', __session: mockJwt }, ), - secretKey: 'pk_test_deadbeef', - isSatellite: true, - signInUrl: 'https://localhost:3000/sign-in/', - domain: 'localhost:3001', - }); + mockOptions({ + secretKey: 'pk_test_deadbeef', + isSatellite: true, + signInUrl: 'https://localhost:3000/sign-in/', + domain: 'localhost:3001', + }), + ); assertSignedIn(assert, requestState, { isSatellite: true, @@ -444,10 +443,10 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns interstitial when clientUat > 0 and no cookieToken [8y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '12345' }), - secretKey: 'deadbeef', - }); + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __client_uat: '12345' }), + mockOptions({ secretKey: 'deadbeef' }), + ); assertHandshake(assert, requestState, { reason: AuthErrorReason.ClientUATWithoutSessionToken }); assert.equal(requestState.message, ''); @@ -455,9 +454,7 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed out when clientUat = 0 and no cookieToken [9y]', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions(defaultHeaders, { __client_uat: '0' }), - }); + const requestState = await authenticateRequest(mockRequestWithCookies({}, { __client_uat: '0' }), mockOptions()); assertSignedOut(assert, requestState, { reason: AuthErrorReason.CookieAndUATMissing, @@ -467,10 +464,14 @@ export default (QUnit: QUnit) => { test('cookieToken: returns interstitial when clientUat > cookieToken.iat [10n]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat + 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat + 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); @@ -480,10 +481,14 @@ export default (QUnit: QUnit) => { test('cookieToken: returns signed out when cookieToken.iat >= clientUat and malformed token [10y.1n]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockMalformedJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockMalformedJwt, + }, + ), + mockOptions(), ); const errMessage = @@ -497,10 +502,14 @@ export default (QUnit: QUnit) => { test('cookieToken: returns signed in when cookieToken.iat >= clientUat and valid token [10y.2y]', async assert => { const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); assertSignedIn(assert, requestState); @@ -519,10 +528,14 @@ export default (QUnit: QUnit) => { fakeClock.tick(3600 * 1000); const requestState = await authenticateRequest( - defaultMockCookieAuthOptions(defaultHeaders, { - __client_uat: `${mockJwtPayload.iat - 10}`, - __session: mockJwt, - }), + mockRequestWithCookies( + {}, + { + __client_uat: `${mockJwtPayload.iat - 10}`, + __session: mockJwt, + }, + ), + mockOptions(), ); assertHandshake(assert, requestState, { reason: AuthErrorReason.SessionTokenOutdated }); @@ -531,16 +544,16 @@ export default (QUnit: QUnit) => { }); test('cookieToken: returns signed in for Amazon Cloudfront userAgent', async assert => { - const requestState = await authenticateRequest({ - ...defaultMockCookieAuthOptions( + const requestState = await authenticateRequest( + mockRequestWithCookies( { ...defaultHeaders, 'user-agent': 'Amazon CloudFront', }, { __client_uat: `12345`, __session: mockJwt }, ), - secretKey: 'test_deadbeef', - }); + mockOptions({ secretKey: 'test_deadbeef' }), + ); assertSignedIn(assert, requestState); assertSignedInToAuth(assert, requestState); From 25eeb3f6f694f504d7efdb1db2362adfc945ee4d Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 11 Dec 2023 21:22:09 -0600 Subject: [PATCH 12/19] chore(repo): Add changeset --- .changeset/nice-doors-fail.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/nice-doors-fail.md diff --git a/.changeset/nice-doors-fail.md b/.changeset/nice-doors-fail.md new file mode 100644 index 00000000000..50e348c22d2 --- /dev/null +++ b/.changeset/nice-doors-fail.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': major +--- + +- Refactor the `authenticateRequest()` flow to use the new client handshake endpoint. This replaces the previous "interstitial"-based flow. This should improve performance and overall reliability of Clerk's server-side request authentication functionality. +- `authenticateRequest()` now accepts two arguments, a `Request` object to authenticate and options: + ```ts + authenticateRequest(new Request(...), { secretKey: '...' }) + ``` From 2a7750ba1d4ffae929aec6dc2b66e1594b57b582 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 12 Dec 2023 16:32:43 +0200 Subject: [PATCH 13/19] feat(*): Drop interstitial (#2304) * feat(backend): Remove interstitial endpoints * feat(backend,types): Remove local interstitial script * feat(types): Clean retheme types * feat(backend): Remove interstitial and interstitial rules * feat(clerk-js): Remove interstitial from clerk-js * feat(nextjs): Remove interstitial from authMiddleware * feat(fastify): Remove interstitial * feat(gatsby-plugin-clerk): Remove interstitial * feat(remix): Remove interstitial * feat(clerk-sdk-node): Remove interstitial * fix(nextjs): Always respect redirect header if found As it's possible that we trigger a redirect from authenticateRequest that isn't a handshake status (dev multi-domain sync, for example) * chore(repo): Fix sdk tests * fix(clerk-js): Fix tests related to db-jwt * fix(clerk-js): Keep hasJustSynced check --- packages/backend/README.md | 22 -- .../src/api/endpoints/InterstitialApi.ts | 21 -- packages/backend/src/api/endpoints/index.ts | 1 - packages/backend/src/api/factory.ts | 2 - packages/backend/src/exports.test.ts | 1 - packages/backend/src/tokens/authStatus.ts | 95 +-------- packages/backend/src/tokens/errors.ts | 2 - packages/backend/src/tokens/factory.ts | 13 -- packages/backend/src/tokens/index.ts | 1 - packages/backend/src/tokens/interstitial.ts | 105 ---------- .../backend/src/tokens/interstitialRule.ts | 197 ------------------ packages/backend/src/tokens/request.test.ts | 37 +--- .../clerk-js/src/core/clerk.redirects.test.ts | 4 +- packages/clerk-js/src/core/clerk.test.ts | 6 +- packages/clerk-js/src/core/clerk.ts | 6 +- .../src/core/devBrowserHandler.test.ts | 18 +- packages/clerk-js/src/core/fapiClient.test.ts | 8 +- .../src/__snapshots__/constants.test.ts.snap | 3 + packages/fastify/src/utils.ts | 25 +++ .../fastify/src/withClerkMiddleware.test.ts | 108 +++------- packages/fastify/src/withClerkMiddleware.ts | 53 ++--- .../src/GatsbyClerkProvider.tsx | 20 +- .../src/ssr/authenticateRequest.ts | 48 ----- packages/gatsby-plugin-clerk/src/ssr/utils.ts | 39 +++- .../src/ssr/withServerAuth.ts | 29 +-- .../__snapshots__/exports.test.ts.snap | 1 - .../nextjs/src/server/authMiddleware.test.ts | 98 ++++----- packages/nextjs/src/server/authMiddleware.ts | 44 ++-- .../nextjs/src/server/authenticateRequest.ts | 57 ----- packages/nextjs/src/server/utils.ts | 7 + .../remix/src/client/ClerkErrorBoundary.tsx | 32 --- packages/remix/src/client/Interstitial.tsx | 5 - packages/remix/src/client/index.ts | 1 - packages/remix/src/client/types.ts | 1 - packages/remix/src/ssr/authenticateRequest.ts | 4 - packages/remix/src/ssr/getAuth.ts | 14 +- packages/remix/src/ssr/rootAuthLoader.ts | 14 +- packages/remix/src/ssr/utils.ts | 33 +-- .../__snapshots__/exports.test.ts.snap | 1 - .../src/__tests__/authenticateRequest.test.ts | 16 +- .../sdk-node/src/__tests__/middleware.test.ts | 142 ------------- packages/sdk-node/src/authenticateRequest.ts | 85 ++------ packages/sdk-node/src/clerkClient.ts | 7 +- .../sdk-node/src/clerkExpressRequireAuth.ts | 30 +-- packages/sdk-node/src/clerkExpressWithAuth.ts | 31 +-- packages/types/src/clerk.retheme.ts | 6 - packages/types/src/clerk.ts | 6 - 47 files changed, 269 insertions(+), 1230 deletions(-) delete mode 100644 packages/backend/src/api/endpoints/InterstitialApi.ts delete mode 100644 packages/backend/src/tokens/interstitial.ts delete mode 100644 packages/backend/src/tokens/interstitialRule.ts create mode 100644 packages/fastify/src/utils.ts delete mode 100644 packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts delete mode 100644 packages/nextjs/src/server/authenticateRequest.ts delete mode 100644 packages/remix/src/client/ClerkErrorBoundary.tsx delete mode 100644 packages/remix/src/client/Interstitial.tsx diff --git a/packages/backend/README.md b/packages/backend/README.md index 797b8b788a5..b8f7f821a5d 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -40,7 +40,6 @@ This package provides Clerk Backend API resources and low-level authentication u - Support multiple CLERK_API_KEY for multiple instance REST access. - Align JWT key resolution algorithm across all environments (Function param > Environment variable > JWKS from API). - Tested automatically across different runtimes (Node, CF Workers, Vercel Edge middleware.) -- Clean up Clerk interstitial logic. - Refactor the Rest Client API to return `{data, errors}` instead of throwing errors. - Export a generic verifyToken for Clerk JWTs verification. - Align AuthData interface for SSR. @@ -80,7 +79,6 @@ clerk.allowlistIdentifiers; clerk.clients; clerk.emailAddresses; clerk.emails; -clerk.interstitial; clerk.invitations; clerk.organizations; clerk.phoneNumbers; @@ -96,12 +94,6 @@ clerk.authenticateRequest(options); // Build debug payload of the request state. clerk.debugRequestState(requestState); - -// Load clerk interstitial from this package -clerk.localInterstitial(options); - -// Load clerk interstitial from the public Private API endpoint (Deprecated) -clerk.remotePrivateInterstitial(options); ``` #### verifyToken(token: string, options: VerifyTokenOptions) @@ -161,20 +153,6 @@ import { debugRequestState } from '@clerk/backend'; debugRequestState(requestState); ``` -#### loadInterstitialFromLocal(options) - -Generates a debug payload for the request state. The debug payload is available via `window.__clerk_debug`. - -```js -import { loadInterstitialFromLocal } from '@clerk/backend'; - -loadInterstitialFromLocal({ - frontendApi: '...', - clerkJSVersion: '...', - debugData: {}, -}); -``` - #### signedInAuthObject(sessionClaims, options) Builds the AuthObject when the user is signed in. diff --git a/packages/backend/src/api/endpoints/InterstitialApi.ts b/packages/backend/src/api/endpoints/InterstitialApi.ts deleted file mode 100644 index 2ce6cc967c3..00000000000 --- a/packages/backend/src/api/endpoints/InterstitialApi.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { deprecated } from '../../util/shared'; -import { AbstractAPI } from './AbstractApi'; -/** - * @deprecated Switch to the public interstitial endpoint from Clerk Backend API. - */ -export class InterstitialAPI extends AbstractAPI { - public async getInterstitial() { - deprecated( - 'getInterstitial()', - 'Switch to `Clerk(...).localInterstitial(...)` from `import { Clerk } from "@clerk/backend"`.', - ); - - return this.request({ - path: 'internal/interstitial', - method: 'GET', - headerParams: { - 'Content-Type': 'text/html', - }, - }); - } -} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index e4f743550ad..9d823d9de1a 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -4,7 +4,6 @@ export * from './ClientApi'; export * from './DomainApi'; export * from './EmailAddressApi'; export * from './EmailApi'; -export * from './InterstitialApi'; export * from './InvitationApi'; export * from './OrganizationApi'; export * from './PhoneNumberApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index aa8f94c5c42..6a9e63b4c6c 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -4,7 +4,6 @@ import { DomainAPI, EmailAddressAPI, EmailAPI, - InterstitialAPI, InvitationAPI, OrganizationAPI, PhoneNumberAPI, @@ -27,7 +26,6 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { clients: new ClientAPI(request), emailAddresses: new EmailAddressAPI(request), emails: new EmailAPI(request), - interstitial: new InterstitialAPI(request), invitations: new InvitationAPI(request), organizations: new OrganizationAPI(request), phoneNumbers: new PhoneNumberAPI(request), diff --git a/packages/backend/src/exports.test.ts b/packages/backend/src/exports.test.ts index 2b8bca5c84a..97bed0993d5 100644 --- a/packages/backend/src/exports.test.ts +++ b/packages/backend/src/exports.test.ts @@ -42,7 +42,6 @@ export default (QUnit: QUnit) => { 'decodeJwt', 'deserialize', 'hasValidSignature', - 'loadInterstitialFromLocal', 'makeAuthObjectSerializable', 'prunePrivateMetadata', 'redirect', diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 5da3fe83d93..9ad3a87069c 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -8,9 +8,7 @@ import type { TokenVerificationErrorReason } from './errors'; export enum AuthStatus { SignedIn = 'signed-in', SignedOut = 'signed-out', - Interstitial = 'interstitial', Handshake = 'handshake', - Unknown = 'unknown', SessionTokenOutdated = 'SessionTokenOutdated', } @@ -27,8 +25,6 @@ export type SignedInState = { afterSignInUrl: string; afterSignUpUrl: string; isSignedIn: true; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedInAuthObject; headers: Headers; }; @@ -46,31 +42,16 @@ export type SignedOutState = { afterSignInUrl: string; afterSignUpUrl: string; isSignedIn: false; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedOutAuthObject; headers: Headers; }; -export type InterstitialState = Omit & { - status: AuthStatus.Interstitial; - isInterstitial: true; - toAuth: () => null; -}; - export type HandshakeState = Omit & { status: AuthStatus.Handshake; headers: Headers; - isInterstitial: false; toAuth: () => null; }; -export type UnknownState = Omit & { - status: AuthStatus.Unknown; - isInterstitial: false; - isUnknown: true; -}; - export enum AuthErrorReason { DevBrowserSync = 'dev-browser-sync', SessionTokenMissing = 'session-token-missing', @@ -98,7 +79,7 @@ export enum AuthErrorReason { export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; -export type RequestState = SignedInState | SignedOutState | InterstitialState | HandshakeState | UnknownState; +export type RequestState = SignedInState | SignedOutState | HandshakeState; type LoadResourcesOptions = { loadSession?: boolean; @@ -200,8 +181,6 @@ export async function signedIn( afterSignInUrl, afterSignUpUrl, isSignedIn: true, - isInterstitial: false, - isUnknown: false, toAuth: () => authObject, headers, }; @@ -236,49 +215,11 @@ export function signedOut( afterSignInUrl, afterSignUpUrl, isSignedIn: false, - isInterstitial: false, - isUnknown: false, headers, toAuth: () => signedOutAuthObject({ ...options, status: AuthStatus.SignedOut, reason, message }), }; } -export function interstitial( - options: T, - reason: AuthReason, - message = '', -): InterstitialState { - const { - publishableKey = '', - proxyUrl = '', - isSatellite = false, - domain = '', - signInUrl = '', - signUpUrl = '', - afterSignInUrl = '', - afterSignUpUrl = '', - } = options; - - return { - status: AuthStatus.Interstitial, - reason, - message, - publishableKey, - isSatellite, - domain, - proxyUrl, - signInUrl, - signUpUrl, - afterSignInUrl, - afterSignUpUrl, - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => null, - headers: new Headers(), - }; -} - export function handshake( options: T, reason: AuthReason, @@ -309,41 +250,7 @@ export function handshake( afterSignInUrl, afterSignUpUrl, isSignedIn: false, - isUnknown: false, headers, - isInterstitial: false, - toAuth: () => null, - }; -} - -export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, message = ''): UnknownState { - const { - publishableKey = '', - proxyUrl = '', - isSatellite = false, - domain = '', - signInUrl = '', - signUpUrl = '', - afterSignInUrl = '', - afterSignUpUrl = '', - } = options; - - return { - status: AuthStatus.Unknown, - reason, - message, - publishableKey, - isSatellite, - domain, - proxyUrl, - signInUrl, - signUpUrl, - afterSignInUrl, - afterSignUpUrl, - isSignedIn: false, - isInterstitial: false, - isUnknown: true, toAuth: () => null, - headers: new Headers(), }; } diff --git a/packages/backend/src/tokens/errors.ts b/packages/backend/src/tokens/errors.ts index bb7757fa5fc..15a7580f465 100644 --- a/packages/backend/src/tokens/errors.ts +++ b/packages/backend/src/tokens/errors.ts @@ -21,8 +21,6 @@ export enum TokenVerificationErrorReason { RemoteJWKMissing = 'jwk-remote-missing', JWKFailedToResolve = 'jwk-failed-to-resolve', - - RemoteInterstitialFailedToLoad = 'interstitial-remote-failed-to-load', } export enum TokenVerificationErrorAction { diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index f9de398e97b..a59681e7d9d 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -1,7 +1,5 @@ import type { ApiClient } from '../api'; import { mergePreDefinedOptions } from '../util/mergePreDefinedOptions'; -import type { LoadInterstitialOptions } from './interstitial'; -import { loadInterstitialFromLocal } from './interstitial'; import type { AuthenticateRequestOptions } from './request'; import { authenticateRequest as authenticateRequestOriginal, debugRequestState } from './request'; @@ -40,7 +38,6 @@ export type CreateAuthenticateRequestOptions = { }; export function createAuthenticateRequest(params: CreateAuthenticateRequestOptions) { - const { apiClient } = params; const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => { @@ -56,18 +53,8 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio }); }; - const localInterstitial = (options: Omit) => { - const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); - return loadInterstitialFromLocal({ ...options, ...runTimeOptions }); - }; - - // TODO: Replace this function with remotePublicInterstitial - const remotePrivateInterstitial = () => apiClient.interstitial.getInterstitial(); - return { authenticateRequest, - localInterstitial, - remotePrivateInterstitial, debugRequestState, }; } diff --git a/packages/backend/src/tokens/index.ts b/packages/backend/src/tokens/index.ts index 926783d53ba..50642216e4b 100644 --- a/packages/backend/src/tokens/index.ts +++ b/packages/backend/src/tokens/index.ts @@ -3,6 +3,5 @@ export { AuthStatus } from './authStatus'; export type { RequestState } from './authStatus'; export { TokenVerificationError, TokenVerificationErrorReason } from './errors'; export * from './factory'; -export { loadInterstitialFromLocal } from './interstitial'; export { debugRequestState } from './request'; export type { AuthenticateRequestOptions, OptionalVerifyTokenOptions } from './request'; diff --git a/packages/backend/src/tokens/interstitial.ts b/packages/backend/src/tokens/interstitial.ts deleted file mode 100644 index 2e9b444d290..00000000000 --- a/packages/backend/src/tokens/interstitial.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { MultiDomainAndOrProxyPrimitives } from '@clerk/types'; - -// DO NOT CHANGE: Runtime needs to be imported as a default export so that we can stub its dependencies with Sinon.js -// For more information refer to https://sinonjs.org/how-to/stub-dependency/ -import { addClerkPrefix, getScriptUrl, isDevOrStagingUrl, parsePublishableKey } from '../util/shared'; -import type { DebugRequestSate } from './request'; - -export type LoadInterstitialOptions = { - apiUrl: string; - publishableKey: string; - clerkJSUrl?: string; - clerkJSVersion?: string; - userAgent?: string; - debugData?: DebugRequestSate; - isSatellite?: boolean; - signInUrl?: string; -} & MultiDomainAndOrProxyPrimitives; - -export function loadInterstitialFromLocal(options: Omit) { - const frontendApi = parsePublishableKey(options.publishableKey)?.frontendApi || ''; - const domainOnlyInProd = !isDevOrStagingUrl(frontendApi) ? addClerkPrefix(options.domain) : ''; - const { - debugData, - clerkJSUrl, - clerkJSVersion, - publishableKey, - proxyUrl, - isSatellite = false, - domain, - signInUrl, - } = options; - return ` - - - - - - - -`; -} diff --git a/packages/backend/src/tokens/interstitialRule.ts b/packages/backend/src/tokens/interstitialRule.ts deleted file mode 100644 index 503f214b543..00000000000 --- a/packages/backend/src/tokens/interstitialRule.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { checkCrossOrigin } from '../util/request'; -import { isDevelopmentFromSecretKey, isProductionFromSecretKey } from '../util/shared'; -import type { AuthStatusOptionsType, RequestState } from './authStatus'; -import { AuthErrorReason, interstitial, signedIn, signedOut } from './authStatus'; -import { verifyToken } from './verify'; - -export type InterstitialRuleOptions = AuthStatusOptionsType & { - /* Request origin header value */ - origin?: string; - /* Request host header value */ - host?: string; - /* Request forwarded host value */ - forwardedHost?: string; - /* Request forwarded proto value */ - forwardedProto?: string; - /* Request referrer */ - referrer?: string; - /* Request user-agent value */ - userAgent?: string; - /* Client token cookie value */ - cookieToken?: string; - /* Client uat cookie value */ - clientUat?: string; - /* Client token header value */ - headerToken?: string; - /* Request search params value */ - searchParams?: URLSearchParams; - /* Derived Request URL */ - derivedRequestUrl?: URL; -}; - -type InterstitialRuleResult = RequestState | undefined; -type InterstitialRule = (opts: InterstitialRuleOptions) => Promise | InterstitialRuleResult; - -const shouldRedirectToSatelliteUrl = (qp?: URLSearchParams) => !!qp?.get('__clerk_satellite_url'); -const hasJustSynced = (qp?: URLSearchParams) => qp?.get('__clerk_synced') === 'true'; - -const VALID_USER_AGENTS = /^Mozilla\/|(Amazon CloudFront)/; - -const isBrowser = (userAgent: string | undefined) => VALID_USER_AGENTS.test(userAgent || ''); - -// In development or staging environments only, based on the request's -// User Agent, detect non-browser requests (e.g. scripts). Since there -// is no Authorization header, consider the user as signed out and -// prevent interstitial rendering -// In production, script requests will be missing both uat and session cookies, which will be -// automatically treated as signed out. This exception is needed for development, because the any // missing uat throws an interstitial in development. -export const nonBrowserRequestInDevRule: InterstitialRule = options => { - const { secretKey, userAgent } = options; - if (isDevelopmentFromSecretKey(secretKey || '') && !isBrowser(userAgent)) { - return signedOut(options, AuthErrorReason.HeaderMissingNonBrowser); - } - return undefined; -}; - -export const crossOriginRequestWithoutHeader: InterstitialRule = options => { - const { origin, host, forwardedHost, forwardedProto } = options; - const isCrossOrigin = - origin && - checkCrossOrigin({ - originURL: new URL(origin), - host, - forwardedHost, - forwardedProto, - }); - - if (isCrossOrigin) { - return signedOut(options, AuthErrorReason.HeaderMissingCORS); - } - return undefined; -}; - -export const isPrimaryInDevAndRedirectsToSatellite: InterstitialRule = options => { - const { secretKey = '', isSatellite, searchParams } = options; - const isDev = isDevelopmentFromSecretKey(secretKey); - - if (isDev && !isSatellite && shouldRedirectToSatelliteUrl(searchParams)) { - return interstitial(options, AuthErrorReason.PrimaryRespondsToSyncing); - } - return undefined; -}; - -export const potentialFirstLoadInDevWhenUATMissing: InterstitialRule = options => { - const { secretKey = '', clientUat } = options; - const res = isDevelopmentFromSecretKey(secretKey); - if (res && !clientUat) { - return interstitial(options, AuthErrorReason.CookieUATMissing); - } - return undefined; -}; - -/** - * NOTE: Exclude any satellite app that has just synced from throwing an interstitial. - * It is expected that a primary app will trigger a redirect back to the satellite app. - */ -export const potentialRequestAfterSignInOrOutFromClerkHostedUiInDev: InterstitialRule = options => { - const { secretKey = '', referrer, host, forwardedHost, forwardedProto } = options; - const crossOriginReferrer = - referrer && checkCrossOrigin({ originURL: new URL(referrer), host, forwardedHost, forwardedProto }); - - if (isDevelopmentFromSecretKey(secretKey) && crossOriginReferrer) { - return interstitial(options, AuthErrorReason.CrossOriginReferrer); - } - return undefined; -}; - -export const potentialFirstRequestOnProductionEnvironment: InterstitialRule = options => { - const { secretKey = '', clientUat, cookieToken } = options; - - if (isProductionFromSecretKey(secretKey) && !clientUat && !cookieToken) { - return signedOut(options, AuthErrorReason.CookieAndUATMissing); - } - return undefined; -}; - -// TBD: Can enable if we do not want the __session cookie to be inspected. -// const signedOutOnDifferentSubdomainButCookieNotRemovedYet: AuthStateRule = (options, key) => { -// if (isProduction(key) && !options.clientUat && !options.cookieToken) { -// return { status: AuthStatus.Interstitial, errorReason: '' as any }; -// } -// }; -export const isNormalSignedOutState: InterstitialRule = options => { - const { clientUat } = options; - if (clientUat === '0') { - return signedOut(options, AuthErrorReason.StandardSignedOut); - } - return undefined; -}; - -// This happens when a signed in user visits a new subdomain for the first time. The uat will be available because it's set on naked domain, but session will be missing. It can also happen if the cookieToken is manually removed during development. -export const hasPositiveClientUatButCookieIsMissing: InterstitialRule = options => { - const { clientUat, cookieToken } = options; - - if (clientUat && Number.parseInt(clientUat) > 0 && !cookieToken) { - return interstitial(options, AuthErrorReason.CookieMissing); - } - return undefined; -}; - -export const hasValidHeaderToken: InterstitialRule = async options => { - const { headerToken } = options; - const sessionClaims = await verifyRequestState(options, headerToken as string); - return await signedIn(options, sessionClaims); -}; - -export const hasValidCookieToken: InterstitialRule = async options => { - const { cookieToken, clientUat } = options; - const sessionClaims = await verifyRequestState(options, cookieToken as string); - const state = await signedIn(options, sessionClaims); - - const jwt = state.toAuth().sessionClaims; - const cookieTokenIsOutdated = jwt.iat < Number.parseInt(clientUat as string); - - if (!clientUat || cookieTokenIsOutdated) { - return interstitial(options, AuthErrorReason.CookieOutDated); - } - - return state; -}; - -export async function runInterstitialRules( - opts: T, - rules: InterstitialRule[], -): Promise { - for (const rule of rules) { - const res = await rule(opts); - if (res) { - return res; - } - } - - return signedOut(opts, AuthErrorReason.UnexpectedError); -} - -async function verifyRequestState(options: InterstitialRuleOptions, token: string) { - return verifyToken(token, { ...options }); -} - -/** - * Avoid throwing this rule for development instances - * Let the next rule for UatMissing to fire if needed - */ -export const isSatelliteAndNeedsSyncing: InterstitialRule = options => { - const { clientUat, isSatellite, searchParams, userAgent } = options; - - const isSignedOut = !clientUat || clientUat === '0'; - - if (isSatellite && isSignedOut && !isBrowser(userAgent)) { - return signedOut(options, AuthErrorReason.SatelliteCookieNeedsSyncing); - } - - if (isSatellite && isSignedOut && !hasJustSynced(searchParams)) { - return interstitial(options, AuthErrorReason.SatelliteCookieNeedsSyncing); - } - - return undefined; -}; diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index d4658e7f5b0..ab673cf2ca8 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -25,8 +25,6 @@ function assertSignedOut( proxyUrl: '', status: AuthStatus.SignedOut, isSignedIn: false, - isInterstitial: false, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -69,7 +67,6 @@ function assertHandshake( proxyUrl: '', status: AuthStatus.Handshake, isSignedIn: false, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -81,24 +78,6 @@ function assertHandshake( }); } -function assertUnknown(assert, requestState: RequestState, reason: AuthReason) { - assert.propContains(requestState, { - publishableKey: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', - status: AuthStatus.Unknown, - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - isSatellite: false, - signInUrl: '', - signUpUrl: '', - afterSignInUrl: '', - afterSignUpUrl: '', - domain: '', - reason, - toAuth: {}, - }); -} - function assertSignedInToAuth(assert, requestState: RequestState) { assert.propContains(requestState.toAuth(), { sessionClaims: mockJwtPayload, @@ -128,8 +107,6 @@ function assertSignedIn( proxyUrl: '', status: AuthStatus.SignedIn, isSignedIn: true, - isInterstitial: false, - isUnknown: false, isSatellite: false, signInUrl: '', signUpUrl: '', @@ -141,7 +118,7 @@ function assertSignedIn( } export default (QUnit: QUnit) => { - const { module, test, skip } = QUnit; + const { module, test } = QUnit; const defaultHeaders: Record = { host: 'example.com', @@ -292,7 +269,7 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { + test('cookieToken: returns handshake when clientUat is missing or equals to 0 and is satellite and not is synced [11y]', async assert => { const requestState = await authenticateRequest( mockRequestWithCookies( { @@ -347,7 +324,7 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when app is satellite, returns from primary and is dev instance [13y]', async assert => { + test('cookieToken: returns handshake when app is satellite, returns from primary and is dev instance [13y]', async assert => { const requestState = await authenticateRequest( mockRequestWithCookies({}, {}, `http://satellite.example/path?__clerk_synced=true&__clerk_db_jwt=${mockJwt}`), mockOptions({ @@ -368,7 +345,7 @@ export default (QUnit: QUnit) => { assert.strictEqual(requestState.toAuth(), null); }); - test('cookieToken: returns interstitial when app is not satellite and responds to syncing on dev instances[12y]', async assert => { + test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async assert => { const sp = new URLSearchParams(); sp.set('__clerk_redirect_url', 'http://localhost:3000'); const requestUrl = `http://clerk.com/path?${sp.toString()}`; @@ -402,7 +379,7 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when no clientUat in development [5y]', async assert => { + test('cookieToken: returns handshake when no clientUat in development [5y]', async assert => { const requestState = await authenticateRequest( mockRequestWithCookies({}, { __session: mockJwt }), mockOptions({ @@ -442,7 +419,7 @@ export default (QUnit: QUnit) => { assertSignedInToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when clientUat > 0 and no cookieToken [8y]', async assert => { + test('cookieToken: returns handshake when clientUat > 0 and no cookieToken [8y]', async assert => { const requestState = await authenticateRequest( mockRequestWithCookies({}, { __client_uat: '12345' }), mockOptions({ secretKey: 'deadbeef' }), @@ -462,7 +439,7 @@ export default (QUnit: QUnit) => { assertSignedOutToAuth(assert, requestState); }); - test('cookieToken: returns interstitial when clientUat > cookieToken.iat [10n]', async assert => { + test('cookieToken: returns handshake when clientUat > cookieToken.iat [10n]', async assert => { const requestState = await authenticateRequest( mockRequestWithCookies( {}, diff --git a/packages/clerk-js/src/core/clerk.redirects.test.ts b/packages/clerk-js/src/core/clerk.redirects.test.ts index ae53212d0a9..360e0a7ada5 100644 --- a/packages/clerk-js/src/core/clerk.redirects.test.ts +++ b/packages/clerk-js/src/core/clerk.redirects.test.ts @@ -293,7 +293,7 @@ describe('Clerk singleton - Redirects', () => { }); }); - it('redirects to the provided url without __dev_session in the url', async () => { + it('redirects to the provided url without __clerk_db_jwt in the url', async () => { await clerkForProductionInstance.redirectWithAuth('https://app.example.com'); expect(mockHref).toHaveBeenNthCalledWith(1, 'https://app.example.com/'); @@ -326,7 +326,7 @@ describe('Clerk singleton - Redirects', () => { }); }); - it('redirects to the provided url with __dev_session in the url', async () => { + it('redirects to the provided url with __clerk_db_jwt in the url', async () => { await clerkForProductionInstance.redirectWithAuth('https://app.example.com'); expect(mockHref).toHaveBeenNthCalledWith(1, 'https://app.example.com/'); diff --git a/packages/clerk-js/src/core/clerk.test.ts b/packages/clerk-js/src/core/clerk.test.ts index 0bd743f51df..ad04b50a72b 100644 --- a/packages/clerk-js/src/core/clerk.test.ts +++ b/packages/clerk-js/src/core/clerk.test.ts @@ -1894,7 +1894,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://example.com/some-path', { useQueryParam: true }); - expect(url).toBe('https://example.com/some-path?__dev_session=deadbeef'); + expect(url).toBe('https://example.com/some-path?__clerk_db_jwt=deadbeef'); }); it('uses the query param to propagate the dev_browser JWT to Account Portal pages on dev - non-kima', async () => { @@ -1903,7 +1903,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://accounts.abcef.12345.dev.lclclerk.com'); - expect(url).toBe('https://accounts.abcef.12345.dev.lclclerk.com/?__dev_session=deadbeef'); + expect(url).toBe('https://accounts.abcef.12345.dev.lclclerk.com/?__clerk_db_jwt=deadbeef'); }); it('uses the query param to propagate the dev_browser JWT to Account Portal pages on dev - kima', async () => { @@ -1912,7 +1912,7 @@ describe('Clerk singleton', () => { await sut.load(); const url = sut.buildUrlWithAuth('https://rested-anemone-14.accounts.dev'); - expect(url).toBe('https://rested-anemone-14.accounts.dev/?__dev_session=deadbeef'); + expect(url).toBe('https://rested-anemone-14.accounts.dev/?__clerk_db_jwt=deadbeef'); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 446a7eff995..16b91f94aae 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -134,7 +134,6 @@ const defaultOptions: ClerkOptions = { signUpUrl: undefined, afterSignInUrl: undefined, afterSignUpUrl: undefined, - isInterstitial: false, }; export class Clerk implements ClerkInterface { @@ -1178,6 +1177,7 @@ export class Clerk implements ClerkInterface { } #hasJustSynced = () => getClerkQueryParam(CLERK_SYNCED) === 'true'; + // @ts-expect-error @nikos #clearJustSynced = () => removeClerkQueryParam(CLERK_SYNCED); #buildSyncUrlForDevelopmentInstances = (): string => { @@ -1204,9 +1204,7 @@ export class Clerk implements ClerkInterface { #shouldSyncWithPrimary = (): boolean => { if (this.#hasJustSynced()) { - if (!this.#options.isInterstitial) { - this.#clearJustSynced(); - } + this.#clearJustSynced(); return false; } diff --git a/packages/clerk-js/src/core/devBrowserHandler.test.ts b/packages/clerk-js/src/core/devBrowserHandler.test.ts index 6701c19e1a5..d52059faec9 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.test.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.test.ts @@ -2,11 +2,13 @@ import type { CreateDevBrowserHandlerOptions } from './devBrowserHandler'; import { createDevBrowserHandler } from './devBrowserHandler'; describe('detBrowserHandler', () => { + // @ts-ignore const { getDevBrowserJWT, setDevBrowserJWT, removeDevBrowserJWT } = createDevBrowserHandler( {} as CreateDevBrowserHandlerOptions, ); - describe('localStorage', () => { + // TODO: Add devbrowser tests + describe('get', () => { beforeEach(() => { Object.defineProperty(window, 'localStorage', { value: { @@ -18,18 +20,8 @@ describe('detBrowserHandler', () => { }); }); - it('stores and retrieves the DevBrowser JWT in localStorage', () => { - const mockJWT = 'cafebabe'; - - expect(setDevBrowserJWT(mockJWT)).toBeUndefined(); - expect(window.localStorage.setItem).toHaveBeenNthCalledWith(1, 'clerk-db-jwt', mockJWT); - - getDevBrowserJWT(); - expect(window.localStorage.getItem).toHaveBeenCalledTimes(1); - - expect(removeDevBrowserJWT()).toBeUndefined(); - getDevBrowserJWT(); - expect(window.localStorage.getItem).toHaveBeenCalledTimes(2); + it('todo', () => { + expect(true).toBeTruthy(); }); }); }); diff --git a/packages/clerk-js/src/core/fapiClient.test.ts b/packages/clerk-js/src/core/fapiClient.test.ts index 05354ef2801..7e4094919a3 100644 --- a/packages/clerk-js/src/core/fapiClient.test.ts +++ b/packages/clerk-js/src/core/fapiClient.test.ts @@ -170,12 +170,12 @@ describe('request', () => { }); describe('for production instances', () => { - it.todo('does not append the __dev_session cookie value to the query string'); - it.todo('does not set the __dev_session cookie from the response Clerk-Cookie header'); + it.todo('does not append the __clerk_db_jwt cookie value to the query string'); + it.todo('does not set the __clerk_db_jwt cookie from the response Clerk-Cookie header'); }); describe('for staging or development instances', () => { - it.todo('appends the __dev_session cookie value to the query string'); - it.todo('sets the __dev_session cookie from the response Clerk-Cookie header'); + it.todo('appends the __clerk_db_jwt cookie value to the query string'); + it.todo('sets the __clerk_db_jwt cookie from the response Clerk-Cookie header'); }); }); diff --git a/packages/fastify/src/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__snapshots__/constants.test.ts.snap index b653b78cd85..7e3776bd19f 100644 --- a/packages/fastify/src/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__snapshots__/constants.test.ts.snap @@ -6,6 +6,8 @@ exports[`constants from environment variables 1`] = ` "API_VERSION": "CLERK_API_VERSION", "Cookies": { "ClientUat": "__client_uat", + "DevBrowser": "__clerk_db_jwt", + "Handshake": "__clerk_handshake", "Session": "__session", }, "Headers": { @@ -23,6 +25,7 @@ exports[`constants from environment variables 1`] = ` "Host": "host", "Origin": "origin", "Referrer": "referer", + "SecFetchDest": "sec-fetch-dest", "UserAgent": "user-agent", }, "JWT_KEY": "CLERK_JWT_KEY", diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts new file mode 100644 index 00000000000..cdc923b4726 --- /dev/null +++ b/packages/fastify/src/utils.ts @@ -0,0 +1,25 @@ +import type { FastifyRequest } from 'fastify'; + +export const fastifyRequestToRequest = (req: FastifyRequest): Request => { + const headers = new Headers( + Object.keys(req.headers).reduce((acc, key) => { + const value = req.headers[key]; + if (!value) { + return acc; + } + + if (typeof value === 'string') { + acc.set(key, value); + } else { + acc.set(key, value.join(',')); + } + return acc; + }, new Headers()), + ); + + // Making some manual tests it seems that FastifyRequest populates the req protocol / hostname + // based on the forwarded headers. Nevertheless, we are gonna use a dummy base and the request + // will be fixed by the createIsomorphicRequest. + const dummyOriginReqUrl = new URL(req.url || '', `${req.protocol}://clerk-dummy`); + return new Request(dummyOriginReqUrl, { method: req.method, headers }); +}; diff --git a/packages/fastify/src/withClerkMiddleware.test.ts b/packages/fastify/src/withClerkMiddleware.test.ts index 39490e244c0..5ab4654ef8a 100644 --- a/packages/fastify/src/withClerkMiddleware.test.ts +++ b/packages/fastify/src/withClerkMiddleware.test.ts @@ -4,7 +4,6 @@ import Fastify from 'fastify'; import { clerkPlugin, getAuth } from './index'; const authenticateRequestMock = jest.fn(); -const localInterstitialMock = jest.fn(); jest.mock('@clerk/backend', () => { return { @@ -12,7 +11,6 @@ jest.mock('@clerk/backend', () => { createClerkClient: () => { return { authenticateRequest: (...args: any) => authenticateRequestMock(...args), - localInterstitial: (...args: any) => localInterstitialMock(...args), }; }, }; @@ -26,9 +24,6 @@ describe('withClerkMiddleware(options)', () => { test('handles signin with Authorization Bearer', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: true, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -56,18 +51,15 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); test('handles signin with cookie', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: true, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -95,82 +87,44 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); - test('handles unknown case by terminating the request with empty response and 401 http code', async () => { - authenticateRequestMock.mockResolvedValue({ - isUnknown: true, - isInterstitial: false, - isSignedIn: false, - reason: 'auth-reason', - message: 'auth-message', - toAuth: () => 'mockedAuth', - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ auth }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { - cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', - }, - }); - - expect(response.statusCode).toEqual(401); - expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); - expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); - expect(response.body).toEqual(''); - }); - - test('handles interstitial case by terminating the request with interstitial html page and 401 http code', async () => { - authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: true, - isSignedIn: false, - reason: 'auth-reason', - message: 'auth-message', - toAuth: () => 'mockedAuth', - }); - localInterstitialMock.mockReturnValue('Interstitial'); - const fastify = Fastify(); - await fastify.register(clerkPlugin); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ auth }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { - cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', - }, - }); - - expect(response.statusCode).toEqual(401); - expect(response.headers['content-type']).toEqual('text/html'); - expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); - expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); - expect(response.body).toEqual('Interstitial'); - }); + // @TODO handshake + // test('handles handshake case by redirecting the request to fapi', async () => { + // authenticateRequestMock.mockResolvedValue({ + // reason: 'auth-reason', + // message: 'auth-message', + // toAuth: () => 'mockedAuth', + // }); + // const fastify = Fastify(); + // await fastify.register(clerkPlugin); + // + // fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { + // const auth = getAuth(request); + // reply.send({ auth }); + // }); + // + // const response = await fastify.inject({ + // method: 'GET', + // path: '/', + // headers: { + // cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233', + // }, + // }); + // + // expect(response.statusCode).toEqual(401); + // expect(response.headers['content-type']).toEqual('text/html'); + // expect(response.headers['x-clerk-auth-reason']).toEqual('auth-reason'); + // expect(response.headers['x-clerk-auth-message']).toEqual('auth-message'); + // }); test('handles signout case by populating the req.auth', async () => { authenticateRequestMock.mockResolvedValue({ - isUnknown: false, - isInterstitial: false, - isSignedIn: false, toAuth: () => 'mockedAuth', }); const fastify = Fastify(); @@ -190,9 +144,9 @@ describe('withClerkMiddleware(options)', () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify({ auth: 'mockedAuth' })); expect(authenticateRequestMock).toBeCalledWith( + expect.any(Request), expect.objectContaining({ secretKey: 'TEST_SECRET_KEY', - request: expect.any(Request), }), ); }); diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 2c629da369b..2b88bf1a6c9 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,57 +1,34 @@ -import { createIsomorphicRequest } from '@clerk/backend'; +import { AuthStatus } from '@clerk/backend'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { clerkClient } from './clerkClient'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; +import { fastifyRequestToRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { - return async (req: FastifyRequest, reply: FastifyReply) => { + return async (fastifyRequest: FastifyRequest, reply: FastifyReply) => { const secretKey = options.secretKey || constants.SECRET_KEY; const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; + const req = fastifyRequestToRequest(fastifyRequest); - const requestState = await clerkClient.authenticateRequest({ + const requestState = await clerkClient.authenticateRequest(req, { ...options, secretKey, publishableKey, - request: createIsomorphicRequest((Request, Headers) => { - const requestHeaders = Object.keys(req.headers).reduce( - (acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), - {}, - ); - const headers = new Headers(requestHeaders); - // Making some manual tests it seems that FastifyRequest populates the req protocol / hostname - // based on the forwarded headers. Nevertheless, we are gonna use a dummy base and the request - // will be fixed by the createIsomorphicRequest. - const dummyOriginReqUrl = new URL(req.url || '', `${req.protocol}://clerk-dummy`); - return new Request(dummyOriginReqUrl, { - method: req.method, - headers, - }); - }), }); - // Interstitial cases - if (requestState.isUnknown) { - return reply - .code(401) - .header(constants.Headers.AuthReason, requestState.reason) - .header(constants.Headers.AuthMessage, requestState.message) - .send(); + if (requestState.status === AuthStatus.Handshake) { + // @TODO handshake + // return reply + // .code(401) + // .header(constants.Headers.AuthReason, requestState.reason) + // .header(constants.Headers.AuthMessage, requestState.message) + // .type('text/html') + // .send(...); } - if (requestState.isInterstitial) { - const interstitialHtmlPage = clerkClient.localInterstitial({ publishableKey }); - - return reply - .code(401) - .header(constants.Headers.AuthReason, requestState.reason) - .header(constants.Headers.AuthMessage, requestState.message) - .type('text/html') - .send(interstitialHtmlPage); - } - - // @ts-ignore - req.auth = requestState.toAuth(); + // @ts-expect-error Inject auth so getAuth can read it + fastifyRequest.auth = requestState.toAuth(); }; }; diff --git a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx index e36a9c982d2..50de7845722 100644 --- a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx +++ b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx @@ -1,9 +1,5 @@ import type { ClerkProviderProps } from '@clerk/clerk-react'; -import { - __internal__setErrorThrowerOptions, - ClerkLoaded, - ClerkProvider as ReactClerkProvider, -} from '@clerk/clerk-react'; +import { __internal__setErrorThrowerOptions, ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react'; import { navigate } from 'gatsby'; import React from 'react'; @@ -17,7 +13,7 @@ export type GatsbyClerkProviderProps = { export function ClerkProvider({ children, ...rest }: GatsbyClerkProviderProps) { const { clerkState, ...restProps } = rest; - const { __clerk_ssr_state, __clerk_ssr_interstitial_html } = clerkState?.__internal_clerk_state || {}; + const { __clerk_ssr_state } = clerkState?.__internal_clerk_state || {}; return ( - {__clerk_ssr_interstitial_html ? ( - - - - ) : ( - children - )} + {children} ); } - -export function Interstitial({ html }: { html: string }) { - return ; -} diff --git a/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts b/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts deleted file mode 100644 index 16e2d71c668..00000000000 --- a/packages/gatsby-plugin-clerk/src/ssr/authenticateRequest.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { GetServerDataProps } from 'gatsby'; - -import { PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; -import { clerkClient, constants, createIsomorphicRequest } from './clerkClient'; -import type { WithServerAuthOptions } from './types'; - -export function authenticateRequest(context: GetServerDataProps, options: WithServerAuthOptions) { - return clerkClient.authenticateRequest({ - ...options, - secretKey: SECRET_KEY, - publishableKey: PUBLISHABLE_KEY, - request: createIsomorphicRequest((Request, Headers) => { - const headers = new Headers(Object.fromEntries(context.headers) as Record); - headers.set( - constants.Headers.ForwardedHost, - returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy(context.headers), - ); - return new Request(context.url, { - method: context.method, - headers, - }); - }), - }); -} - -const returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy = (headers: Map) => { - if (process.env.NODE_ENV !== 'development') { - return headers.get(constants.Headers.ForwardedHost) as string; - } - - const forwardedHost = headers.get(constants.Headers.ForwardedHost) as string; - if (forwardedHost) { - return forwardedHost; - } - - const referrerUrl = new URL(headers.get(constants.Headers.Referrer) as string); - const hostUrl = new URL('https://' + (headers.get(constants.Headers.Host) as string)); - - if (isDevelopmentOrStaging(SECRET_KEY || '') && hostUrl.hostname === referrerUrl.hostname) { - return referrerUrl.host; - } - - return forwardedHost; -}; - -function isDevelopmentOrStaging(apiKey: string): boolean { - return apiKey.startsWith('test_') || apiKey.startsWith('sk_test_'); -} diff --git a/packages/gatsby-plugin-clerk/src/ssr/utils.ts b/packages/gatsby-plugin-clerk/src/ssr/utils.ts index 4d305d2228c..fed608ad9ff 100644 --- a/packages/gatsby-plugin-clerk/src/ssr/utils.ts +++ b/packages/gatsby-plugin-clerk/src/ssr/utils.ts @@ -3,6 +3,9 @@ import { prunePrivateMetadata } from '@clerk/backend'; import cookie from 'cookie'; import type { GetServerDataProps } from 'gatsby'; +import { SECRET_KEY } from '../constants'; +import { constants } from './clerkClient'; + /** * @internal */ @@ -38,19 +41,43 @@ export const wrapWithClerkState = (data: any) => { return { clerkState: { __internal_clerk_state: { ...data } } }; }; -/** - * @internal - */ export const parseCookies = (headers: any) => { return cookie.parse(headers.get('cookie') || ''); }; -/** - * @internal - */ export function injectSSRStateIntoProps(callbackResult: any, data: any) { return { ...callbackResult, props: { ...callbackResult.props, ...wrapWithClerkState(data) }, }; } + +export const gatsbyPropsToRequest = (context: GetServerDataProps): Request => { + const headers = new Headers(Object.fromEntries(context.headers) as Record); + headers.set(constants.Headers.ForwardedHost, returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy(context.headers)); + return new Request(context.url, { method: context.method, headers }); +}; + +const returnReferrerAsXForwardedHostToFixLocalDevGatsbyProxy = (headers: Map) => { + if (process.env.NODE_ENV !== 'development') { + return headers.get(constants.Headers.ForwardedHost) as string; + } + + const forwardedHost = headers.get(constants.Headers.ForwardedHost) as string; + if (forwardedHost) { + return forwardedHost; + } + + const referrerUrl = new URL(headers.get(constants.Headers.Referrer) as string); + const hostUrl = new URL('https://' + (headers.get(constants.Headers.Host) as string)); + + if (isDevelopmentOrStaging(SECRET_KEY || '') && hostUrl.hostname === referrerUrl.hostname) { + return referrerUrl.host; + } + + return forwardedHost; +}; + +function isDevelopmentOrStaging(apiKey: string): boolean { + return apiKey.startsWith('test_') || apiKey.startsWith('sk_test_'); +} diff --git a/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts b/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts index 3d2cce8cb13..f69e290ddde 100644 --- a/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts +++ b/packages/gatsby-plugin-clerk/src/ssr/withServerAuth.ts @@ -1,10 +1,10 @@ +import { AuthStatus } from '@clerk/backend'; import type { GetServerDataProps, GetServerDataReturn } from 'gatsby'; -import { PUBLISHABLE_KEY } from '../constants'; -import { authenticateRequest } from './authenticateRequest'; -import { clerkClient, constants } from './clerkClient'; +import { PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; +import { clerkClient } from './clerkClient'; import type { WithServerAuthCallback, WithServerAuthOptions, WithServerAuthResult } from './types'; -import { injectAuthIntoContext, injectSSRStateIntoProps, sanitizeAuthObject } from './utils'; +import { gatsbyPropsToRequest, injectAuthIntoContext, injectSSRStateIntoProps, sanitizeAuthObject } from './utils'; interface WithServerAuth { ( @@ -19,16 +19,17 @@ export const withServerAuth: WithServerAuth = (cbOrOptions: any, options?: any): const opts = (options ? options : typeof cbOrOptions !== 'function' ? cbOrOptions : {}) || {}; return async (props: GetServerDataProps) => { - const requestState = await authenticateRequest(props, opts); - if (requestState.isInterstitial || requestState.isUnknown) { - const headers = { - [constants.Headers.AuthMessage]: requestState.message, - [constants.Headers.AuthStatus]: requestState.status, - }; - const interstitialHtml = clerkClient.localInterstitial({ - publishableKey: PUBLISHABLE_KEY, - }); - return injectSSRStateIntoProps({ headers }, { __clerk_ssr_interstitial_html: interstitialHtml }); + const req = gatsbyPropsToRequest(props); + const requestState = await clerkClient.authenticateRequest(req, { + ...opts, + secretKey: SECRET_KEY, + publishableKey: PUBLISHABLE_KEY, + request: req, + }); + + if (requestState.status === AuthStatus.Handshake) { + // @TODO handle handshake + return; } const contextWithAuth = injectAuthIntoContext(props, requestState.toAuth()); diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap index 6c43fefc75b..f14079b5887 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -42,7 +42,6 @@ exports[`/server public exports should not include a breaking change 1`] = ` "deserialize", "getAuth", "hasValidSignature", - "loadInterstitialFromLocal", "makeAuthObjectSerializable", "prunePrivateMetadata", "redirect", diff --git a/packages/nextjs/src/server/authMiddleware.test.ts b/packages/nextjs/src/server/authMiddleware.test.ts index de85fe73a8e..405f0fa535c 100644 --- a/packages/nextjs/src/server/authMiddleware.test.ts +++ b/packages/nextjs/src/server/authMiddleware.test.ts @@ -1,10 +1,24 @@ // There is no need to execute the complete authenticateRequest to test authMiddleware // This mock SHOULD exist before the import of authenticateRequest +import { AuthStatus } from '@clerk/backend'; import { expectTypeOf } from 'expect-type'; import { NextURL } from 'next/dist/server/web/next-url'; import type { NextFetchEvent, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +const authenticateRequestMock = jest.fn().mockResolvedValue({ + toAuth: () => ({}), +}); + +jest.mock('./clerkClient', () => { + return { + clerkClient: { + authenticateRequest: authenticateRequestMock, + telemetry: { record: jest.fn() }, + }, + }; +}); + const mockRedirectToSignIn = jest.fn().mockImplementation(() => { const res = NextResponse.redirect( 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', @@ -19,8 +33,6 @@ jest.mock('./redirect', () => { }); import { paths, setHeader } from '../utils'; -// used to assert the mock -import { authenticateRequest } from './authenticateRequest'; import { authMiddleware, createRouteMatcher, DEFAULT_CONFIG_MATCHER, DEFAULT_IGNORED_ROUTES } from './authMiddleware'; // used to assert the mock import { clerkClient } from './clerkClient'; @@ -37,17 +49,6 @@ afterAll(() => { global.console.warn = consoleWarn; }); -jest.mock('./authenticateRequest', () => { - const { handleInterstitialState, handleUnknownState } = jest.requireActual('./authenticateRequest'); - return { - authenticateRequest: jest.fn().mockResolvedValue({ - toAuth: () => ({}), - }), - handleInterstitialState, - handleUnknownState, - }; -}); - // Removing this mock will cause the authMiddleware tests to fail due to missing publishable key // This mock SHOULD exist before the imports jest.mock('./constants', () => { @@ -197,13 +198,8 @@ describe('default ignored routes matcher', () => { }); describe('authMiddleware(params)', () => { - beforeAll(() => { - clerkClient.localInterstitial = jest.fn().mockResolvedValue('interstitial'); - }); - beforeEach(() => { - (authenticateRequest as jest.Mock).mockClear(); - (clerkClient.localInterstitial as jest.Mock).mockClear(); + authenticateRequestMock.mockClear(); }); describe('without params', function () { @@ -244,7 +240,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/ignored' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(beforeAuthSpy).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -259,7 +255,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/protected' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(beforeAuthSpy).toBeCalled(); expect(afterAuthSpy).toBeCalled(); }); @@ -326,7 +322,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('skip'); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -338,7 +334,7 @@ describe('authMiddleware(params)', () => { })(mockRequest({ url: '/protected' }), {} as NextFetchEvent); expect(resp?.status).toEqual(200); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(afterAuthSpy).toBeCalled(); }); @@ -352,7 +348,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/custom-redirect'); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).not.toBeCalled(); + expect(clerkClient.authenticateRequest).not.toBeCalled(); expect(afterAuthSpy).not.toBeCalled(); }); @@ -375,7 +371,7 @@ describe('authMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get('x-before-auth-header')).toEqual('before'); expect(resp?.headers.get('x-after-auth-header')).toEqual('after'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); }); @@ -390,19 +386,18 @@ describe('authMiddleware(params)', () => { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('uses authenticateRequest result as auth', async () => { const req = mockRequest({ url: '/protected' }); const event = {} as NextFetchEvent; - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ toAuth: () => ({ userId: null }) }); + authenticateRequestMock.mockResolvedValueOnce({ toAuth: () => ({ userId: null }) }); const afterAuthSpy = jest.fn(); await authMiddleware({ afterAuth: afterAuthSpy })(req, event); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); expect(afterAuthSpy).toBeCalledWith( { userId: null, @@ -416,37 +411,16 @@ describe('authMiddleware(params)', () => { }); describe('authenticateRequest', function () { - it('returns 401 with local interstitial for interstitial requestState', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isInterstitial: true }); - const resp = await authMiddleware()(mockRequest({ url: '/protected' }), {} as NextFetchEvent); - - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('text/html'); - expect(clerkClient.localInterstitial).toBeCalled(); - }); - - it('returns 401 for unknown requestState', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isUnknown: true }); + it('returns 307 and starts the handshake flow for handshake requestState status', async () => { + const mockLocationUrl = 'https://example.com'; + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.Handshake, + headers: new Headers({ Location: mockLocationUrl }), + }); const resp = await authMiddleware()(mockRequest({ url: '/protected' }), {} as NextFetchEvent); - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('application/json'); - expect(clerkClient.localInterstitial).not.toBeCalled(); - }); - - it('returns 401 for interstitial requestState in an API route', async () => { - // @ts-ignore - authenticateRequest.mockResolvedValueOnce({ isInterstitial: true }); - const resp = await authMiddleware({ apiRoutes: ['/api/items'] })( - mockRequest({ url: '/api/items' }), - {} as NextFetchEvent, - ); - - expect(resp?.status).toEqual(401); - expect(resp?.headers.get('content-type')).toEqual('application/json'); - expect(clerkClient.localInterstitial).not.toBeCalled(); + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('Location')).toEqual(mockLocationUrl); }); }); }); @@ -462,7 +436,7 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('appends the Dev Browser JWT to the search when cookie __clerk_db_jwt exists and location is an Account Portal URL', async () => { @@ -472,10 +446,10 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual( - 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__dev_session=test_jwt', + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__clerk_db_jwt=test_jwt', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); it('does NOT append the Dev Browser JWT if x-clerk-redirect-to header is not set', async () => { @@ -493,7 +467,7 @@ describe('Dev Browser JWT when redirecting to cross origin', function () { 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); - expect(authenticateRequest).toBeCalled(); + expect(clerkClient.authenticateRequest).toBeCalled(); }); }); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 6d24c32044e..d7483d24871 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -10,9 +10,8 @@ import { NextResponse } from 'next/server'; import { isRedirect, mergeResponses, paths, setHeader, stringifyHeaders } from '../utils'; import { withLogger } from '../utils/debugLogger'; -import { authenticateRequest } from './authenticateRequest'; import { clerkClient } from './clerkClient'; -import { SECRET_KEY } from './constants'; +import { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; import { informAboutProtectedRouteInfo, receivedRequestForIgnoredRoute } from './errors'; import { redirectToSignIn } from './redirect'; import type { NextMiddlewareResult, WithAuthOptions } from './types'; @@ -20,6 +19,8 @@ import { isDevAccountPortalOrigin } from './url'; import { apiEndpointUnauthorizedNextResponse, decorateRequest, + decorateResponseWithObservabilityHeaders, + handleMultiDomainAndProxy, isCrossOrigin, setRequestHeadersOnNextResponse, } from './utils'; @@ -185,23 +186,24 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { } // TODO: fix type discrepancy between WithAuthOptions and AuthenticateRequestOptions - const requestState = await authenticateRequest(req, options as AuthenticateRequestOptions); - const requestStateHeaders = requestState.headers; - - const locationHeader = requestStateHeaders?.get('location'); - - // triggering a handshake redirect - if (locationHeader) { - return new Response(null, { status: 307, headers: requestStateHeaders }); - } - - if ( - requestState.status === AuthStatus.Handshake || - requestState.status === AuthStatus.Unknown || - requestState.status === AuthStatus.Interstitial - ) { - console.log(requestState); - throw new Error('Unexpected handshake or unknown state without redirect'); + const authenticateRequestOptions = { + ...options, + secretKey: options.secretKey || SECRET_KEY, + publishableKey: options.publishableKey || PUBLISHABLE_KEY, + ...handleMultiDomainAndProxy(req, options as AuthenticateRequestOptions), + } as AuthenticateRequestOptions; + const requestState = await clerkClient.authenticateRequest(req, authenticateRequestOptions); + + if (requestState.status === AuthStatus.Handshake) { + const locationHeader = requestState.headers.get('location'); + if (!locationHeader) { + throw new Error('Unexpected handshake without redirect'); + } + // triggering a handshake redirect + return decorateResponseWithObservabilityHeaders( + new Response(null, { status: 307, headers: requestState.headers }), + requestState, + ); } const auth = Object.assign(requestState.toAuth(), { @@ -226,8 +228,8 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { const result = decorateRequest(req, finalRes, requestState) || NextResponse.next(); - if (requestStateHeaders) { - requestStateHeaders.forEach((value, key) => { + if (requestState.headers) { + requestState.headers.forEach((value, key) => { result.headers.append(key, value); }); } diff --git a/packages/nextjs/src/server/authenticateRequest.ts b/packages/nextjs/src/server/authenticateRequest.ts deleted file mode 100644 index e9fa2dd8d6f..00000000000 --- a/packages/nextjs/src/server/authenticateRequest.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AuthenticateRequestOptions } from '@clerk/backend'; -import { constants, debugRequestState } from '@clerk/backend'; -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; - -import type { RequestState } from './clerkClient'; -import { clerkClient } from './clerkClient'; -import { CLERK_JS_URL, CLERK_JS_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from './constants'; -import { apiEndpointUnauthorizedNextResponse, handleMultiDomainAndProxy } from './utils'; - -export const authenticateRequest = async (req: NextRequest, opts: AuthenticateRequestOptions) => { - const { isSatellite, domain, signInUrl, proxyUrl } = handleMultiDomainAndProxy(req, opts); - return await clerkClient.authenticateRequest(req, { - ...opts, - secretKey: opts.secretKey || SECRET_KEY, - publishableKey: opts.publishableKey || PUBLISHABLE_KEY, - isSatellite, - domain, - signInUrl, - proxyUrl, - }); -}; - -const decorateResponseWithObservabilityHeaders = (res: NextResponse, requestState: RequestState) => { - requestState.message && res.headers.set(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); - requestState.reason && res.headers.set(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); - requestState.status && res.headers.set(constants.Headers.AuthStatus, encodeURIComponent(requestState.status)); -}; - -export const handleUnknownState = (requestState: RequestState) => { - const response = apiEndpointUnauthorizedNextResponse(); - decorateResponseWithObservabilityHeaders(response, requestState); - return response; -}; - -export const handleInterstitialState = (requestState: RequestState, opts: AuthenticateRequestOptions) => { - const response = new NextResponse( - clerkClient.localInterstitial({ - publishableKey: opts.publishableKey || PUBLISHABLE_KEY, - clerkJSUrl: CLERK_JS_URL, - clerkJSVersion: CLERK_JS_VERSION, - proxyUrl: requestState.proxyUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - debugData: debugRequestState(requestState), - signInUrl: requestState.signInUrl, - }), - { - status: 401, - headers: { - 'content-type': 'text/html', - }, - }, - ); - decorateResponseWithObservabilityHeaders(response, requestState); - return response; -}; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index d88a46b2e57..6f244db5963 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -250,3 +250,10 @@ export const handleMultiDomainAndProxy = (req: NextRequest, opts: AuthenticateRe signInUrl, }; }; + +export const decorateResponseWithObservabilityHeaders = (res: Response, requestState: RequestState): Response => { + requestState.message && res.headers.set(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); + requestState.reason && res.headers.set(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); + requestState.status && res.headers.set(constants.Headers.AuthStatus, encodeURIComponent(requestState.status)); + return res; +}; diff --git a/packages/remix/src/client/ClerkErrorBoundary.tsx b/packages/remix/src/client/ClerkErrorBoundary.tsx deleted file mode 100644 index c1f1de184c2..00000000000 --- a/packages/remix/src/client/ClerkErrorBoundary.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { isRouteErrorResponse, useRouteError } from '@remix-run/react'; -import React from 'react'; - -import { Interstitial } from './Interstitial'; - -export function ClerkErrorBoundary(RootErrorBoundary?: React.ComponentType) { - return () => { - const error = useRouteError(); - - if (isRouteErrorResponse(error)) { - const { __clerk_ssr_interstitial_html } = error?.data?.clerkState?.__internal_clerk_state || {}; - if (__clerk_ssr_interstitial_html) { - /** - * In the (unlikely) case we trigger an interstitial during a client-side transition, we need to reload the page so the interstitial can properly trigger. Without a reload, the injected script tag does not get executed. - * Notably, this currently triggers for satellite domain syncing. - */ - if (typeof window !== 'undefined') { - window.location.reload(); - return; - } - - return ; - } - } - - if (!RootErrorBoundary) { - return undefined; - } - - return ; - }; -} diff --git a/packages/remix/src/client/Interstitial.tsx b/packages/remix/src/client/Interstitial.tsx deleted file mode 100644 index 927c0e4d820..00000000000 --- a/packages/remix/src/client/Interstitial.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export function Interstitial({ html }: { html: string }) { - return ; -} diff --git a/packages/remix/src/client/index.ts b/packages/remix/src/client/index.ts index 1bd9d9e0adb..f4fc7a23ea4 100644 --- a/packages/remix/src/client/index.ts +++ b/packages/remix/src/client/index.ts @@ -1,5 +1,4 @@ export * from './RemixClerkProvider'; export { ClerkApp } from './ClerkApp'; -export { ClerkErrorBoundary } from './ClerkErrorBoundary'; export { WithClerkState } from './types'; export { SignIn, SignUp } from './uiComponents'; diff --git a/packages/remix/src/client/types.ts b/packages/remix/src/client/types.ts index b1f0706456e..327c07351fa 100644 --- a/packages/remix/src/client/types.ts +++ b/packages/remix/src/client/types.ts @@ -4,7 +4,6 @@ import type { InitialState } from '@clerk/types'; export type ClerkState = { __type: 'clerkState'; __internal_clerk_state: { - __clerk_ssr_interstitial: string; __clerk_ssr_state: InitialState; __publishableKey: string | undefined; __proxyUrl: string | undefined; diff --git a/packages/remix/src/ssr/authenticateRequest.ts b/packages/remix/src/ssr/authenticateRequest.ts index fb04d5f2e80..fe63f59ee10 100644 --- a/packages/remix/src/ssr/authenticateRequest.ts +++ b/packages/remix/src/ssr/authenticateRequest.ts @@ -10,9 +10,6 @@ import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMis import { getEnvVariable } from '../utils'; import type { LoaderFunctionArgs, RootAuthLoaderOptions } from './types'; -/** - * @internal - */ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoaderOptions = {}): Promise { const { request, context } = args; const { loadSession, loadUser, loadOrganization } = opts; @@ -89,6 +86,5 @@ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoad signUpUrl, afterSignInUrl, afterSignUpUrl, - request, }); } diff --git a/packages/remix/src/ssr/getAuth.ts b/packages/remix/src/ssr/getAuth.ts index e14bfeb4112..8871a5cb1fd 100644 --- a/packages/remix/src/ssr/getAuth.ts +++ b/packages/remix/src/ssr/getAuth.ts @@ -1,9 +1,9 @@ -import { sanitizeAuthObject } from '@clerk/backend'; +import { AuthStatus, sanitizeAuthObject } from '@clerk/backend'; +import { redirect } from '@remix-run/server-runtime'; import { noLoaderArgsPassedInGetAuth } from '../errors'; import { authenticateRequest } from './authenticateRequest'; import type { GetAuthReturn, LoaderFunctionArgs, RootAuthLoaderOptions } from './types'; -import { interstitialJsonResponse, unknownResponse } from './utils'; type GetAuthOptions = Pick; @@ -13,12 +13,10 @@ export async function getAuth(args: LoaderFunctionArgs, opts?: GetAuthOptions): } const requestState = await authenticateRequest(args, opts); - if (requestState.isUnknown) { - throw unknownResponse(requestState); - } - - if (requestState.isInterstitial) { - throw interstitialJsonResponse(requestState, { loader: 'nested' }, args.context); + // TODO handle handshake + // this halts the execution of all nested loaders using getAuth + if (requestState.status === AuthStatus.Handshake) { + throw redirect(''); } return sanitizeAuthObject(requestState.toAuth()); diff --git a/packages/remix/src/ssr/rootAuthLoader.ts b/packages/remix/src/ssr/rootAuthLoader.ts index 23a8ccb83fc..f91094893aa 100644 --- a/packages/remix/src/ssr/rootAuthLoader.ts +++ b/packages/remix/src/ssr/rootAuthLoader.ts @@ -1,5 +1,6 @@ -import { sanitizeAuthObject } from '@clerk/backend'; +import { AuthStatus, sanitizeAuthObject } from '@clerk/backend'; import type { defer } from '@remix-run/server-runtime'; +import { redirect } from '@remix-run/server-runtime'; import { isDeferredData } from '@remix-run/server-runtime/dist/responses'; import { invalidRootLoaderCallbackReturn } from '../errors'; @@ -10,10 +11,8 @@ import { injectAuthIntoRequest, injectRequestStateIntoDeferredData, injectRequestStateIntoResponse, - interstitialJsonResponse, isRedirect, isResponse, - unknownResponse, } from './utils'; interface RootAuthLoader { @@ -51,12 +50,9 @@ export const rootAuthLoader: RootAuthLoader = async ( const requestState = await authenticateRequest(args, opts); - if (requestState.isUnknown) { - throw unknownResponse(requestState); - } - - if (requestState.isInterstitial) { - throw interstitialJsonResponse(requestState, { loader: 'root' }, args.context); + // TODO handle handshake + if (requestState.status === AuthStatus.Handshake) { + throw redirect(''); } if (!handler) { diff --git a/packages/remix/src/ssr/utils.ts b/packages/remix/src/ssr/utils.ts index a1d1e5a6097..dd48b5c3d81 100644 --- a/packages/remix/src/ssr/utils.ts +++ b/packages/remix/src/ssr/utils.ts @@ -1,5 +1,5 @@ import type { AuthObject, RequestState } from '@clerk/backend'; -import { constants, debugRequestState, loadInterstitialFromLocal } from '@clerk/backend'; +import { constants, debugRequestState } from '@clerk/backend'; import { isTruthy } from '@clerk/shared/underscore'; import type { AppLoadContext, defer } from '@remix-run/server-runtime'; import { json } from '@remix-run/server-runtime'; @@ -73,35 +73,6 @@ export const getClerkDebugHeaders = (headers: Headers) => { }; }; -export const unknownResponse = (requestState: RequestState) => { - return json(null, { status: 401, headers: observabilityHeadersFromRequestState(requestState) }); -}; - -export const interstitialJsonResponse = ( - requestState: RequestState, - opts: { loader: 'root' | 'nested' }, - context: AppLoadContext, -) => { - return json( - wrapWithClerkState({ - __loader: opts.loader, - __clerk_ssr_interstitial_html: loadInterstitialFromLocal({ - debugData: debugRequestState(requestState), - publishableKey: requestState.publishableKey, - // TODO: This needs to be the version of clerk/remix not clerk/react - // pkgVersion: LIB_VERSION, - clerkJSUrl: getEnvVariable('CLERK_JS', context), - clerkJSVersion: getEnvVariable('CLERK_JS_VERSION', context), - proxyUrl: requestState.proxyUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - signInUrl: requestState.signInUrl, - }), - }), - { status: 401, headers: observabilityHeadersFromRequestState(requestState) }, - ); -}; - export const injectRequestStateIntoResponse = async ( response: Response, requestState: RequestState, @@ -150,7 +121,7 @@ export function injectRequestStateIntoDeferredData( * @internal */ export function getResponseClerkState(requestState: RequestState, context: AppLoadContext) { - const { reason, message, isSignedIn, isInterstitial, ...rest } = requestState; + const { reason, message, isSignedIn, ...rest } = requestState; const clerkState = wrapWithClerkState({ __clerk_ssr_state: rest.toAuth(), __publishableKey: requestState.publishableKey, diff --git a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap index 4e57c549bbb..a06ef71aa5c 100644 --- a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap @@ -48,7 +48,6 @@ exports[`module exports should not change unless explicitly set 1`] = ` "emails", "hasValidSignature", "invitations", - "loadInterstitialFromLocal", "makeAuthObjectSerializable", "organizations", "phoneNumbers", diff --git a/packages/sdk-node/src/__tests__/authenticateRequest.test.ts b/packages/sdk-node/src/__tests__/authenticateRequest.test.ts index 5ae3005c988..44b602357b3 100644 --- a/packages/sdk-node/src/__tests__/authenticateRequest.test.ts +++ b/packages/sdk-node/src/__tests__/authenticateRequest.test.ts @@ -1,5 +1,5 @@ -import { constants, createIsomorphicRequest } from '@clerk/backend'; -import type { Request } from 'express'; +import { constants } from '@clerk/backend'; +import { Request } from 'express'; import { authenticateRequest } from '../authenticateRequest'; @@ -39,14 +39,6 @@ describe('authenticateRequest', () => { const searchParams = new URLSearchParams(); searchParams.set('__query', 'true'); - const expectedIsomorphicRequest = createIsomorphicRequest((Request, Headers) => { - // @ts-ignore - return new Request(req.url, { - // @ts-ignore - headers: new Headers(req.headers), - }); - }); - await authenticateRequest({ clerkClient: clerkClient as any, secretKey, @@ -54,9 +46,10 @@ describe('authenticateRequest', () => { req, options, }); + expect(clerkClient.authenticateRequest).toHaveBeenCalledWith( + expect.any(Request), expect.objectContaining({ - authorizedParties: ['party1'], secretKey: secretKey, publishableKey: publishableKey, jwtKey: 'jwtKey', @@ -64,7 +57,6 @@ describe('authenticateRequest', () => { proxyUrl: '', signInUrl: '', domain: '', - request: expect.objectContaining(expectedIsomorphicRequest), }), ); }); diff --git a/packages/sdk-node/src/__tests__/middleware.test.ts b/packages/sdk-node/src/__tests__/middleware.test.ts index d35fe2c0966..417148b7a26 100644 --- a/packages/sdk-node/src/__tests__/middleware.test.ts +++ b/packages/sdk-node/src/__tests__/middleware.test.ts @@ -15,8 +15,6 @@ afterEach(() => { const mockClerkClient = () => ({ authenticateRequest: jest.fn(), - remotePrivateInterstitial: jest.fn(), - localInterstitial: jest.fn(), }); describe('ClerkExpressWithAuth', () => { @@ -26,8 +24,6 @@ describe('ClerkExpressWithAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, toAuth: () => ({ sessionId: null }), } as RequestState); @@ -43,8 +39,6 @@ describe('ClerkExpressWithAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: true, - isInterstitial: false, toAuth: () => ({ sessionId: '1' }), } as RequestState); @@ -52,72 +46,6 @@ describe('ClerkExpressWithAuth', () => { expect((req as WithAuthProp).auth.sessionId).toEqual('1'); expect(mockNext).toHaveBeenCalledWith(); }); - - it('should halt middleware execution and return empty response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith(); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return remote private interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - clerkClient.remotePrivateInterstitial.mockReturnValue({ data: 'interstitial', errors: null }); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return local interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - publishableKey: 'pk_12345', - } as unknown as RequestState); - clerkClient.localInterstitial.mockReturnValue('interstitial'); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); }); describe('ClerkExpressRequireAuth', () => { @@ -127,8 +55,6 @@ describe('ClerkExpressRequireAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, toAuth: () => ({ sessionId: null }), } as RequestState); @@ -145,79 +71,11 @@ describe('ClerkExpressRequireAuth', () => { const clerkClient = mockClerkClient() as any; clerkClient.authenticateRequest.mockReturnValue({ isSignedIn: true, - isInterstitial: false, toAuth: () => ({ sessionId: '1' }), } as RequestState); await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - expect((req as WithAuthProp).auth.sessionId).toEqual('1'); expect(mockNext).toHaveBeenCalledWith(); }); - - it('should halt middleware execution and return empty response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: false, - isUnknown: true, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - - await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith(); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return remote private interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - } as unknown as RequestState); - clerkClient.remotePrivateInterstitial.mockReturnValue({ data: 'interstitial', errors: null }); - - await createClerkExpressRequireAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); - - it('should halt middleware execution and return local interstitial response with 401 http code for unknown request state', async () => { - const writeHeadSpy = jest.fn(); - const endSpy = jest.fn(); - const req = createRequest(); - const res = { writeHead: writeHeadSpy, end: endSpy } as unknown as Response; - - const clerkClient = mockClerkClient() as any; - clerkClient.authenticateRequest.mockReturnValue({ - isSignedIn: false, - isInterstitial: true, - isUnknown: false, - toAuth: () => ({ sessionId: '1' }), - publishableKey: 'pk_12345', - } as unknown as RequestState); - clerkClient.localInterstitial.mockReturnValue('interstitial'); - - await createClerkExpressWithAuth({ clerkClient })()(req, res, mockNext as NextFunction); - - expect(writeHeadSpy).toBeCalledWith(401, { 'Content-Type': 'text/html' }); - expect(endSpy).toBeCalledWith('interstitial'); - expect(mockNext).not.toBeCalled(); - }); }); diff --git a/packages/sdk-node/src/authenticateRequest.ts b/packages/sdk-node/src/authenticateRequest.ts index f7b6f6d72a0..81767bff8d0 100644 --- a/packages/sdk-node/src/authenticateRequest.ts +++ b/packages/sdk-node/src/authenticateRequest.ts @@ -1,66 +1,20 @@ import type { RequestState } from '@clerk/backend'; -import { buildRequestUrl, constants, createIsomorphicRequest } from '@clerk/backend'; +import { buildRequestUrl, constants } from '@clerk/backend'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl } from '@clerk/shared/proxy'; -import type { ServerResponse } from 'http'; +import type { IncomingMessage, ServerResponse } from 'http'; -import type { AuthenticateRequestParams, ClerkClient } from './types'; +import type { AuthenticateRequestParams } from './types'; import { loadApiEnv, loadClientEnv } from './utils'; -export async function loadInterstitial({ - clerkClient, - requestState, -}: { - clerkClient: ClerkClient; - requestState: RequestState; -}) { - const { clerkJSVersion, clerkJSUrl } = loadClientEnv(); - /** - * When publishable key is present utilize the localInterstitial method - * and avoid the extra network call - */ - if (requestState.publishableKey) { - const data = clerkClient.localInterstitial({ - publishableKey: requestState.publishableKey, - proxyUrl: requestState.proxyUrl, - signInUrl: requestState.signInUrl, - isSatellite: requestState.isSatellite, - domain: requestState.domain, - clerkJSVersion, - clerkJSUrl, - }); - - return { - data, - errors: null, - }; - } - - return clerkClient.remotePrivateInterstitial(); -} - export const authenticateRequest = (opts: AuthenticateRequestParams) => { - const { clerkClient, secretKey, publishableKey, req, options } = opts; + const { clerkClient, secretKey, publishableKey, req: incomingMessage, options } = opts; const { jwtKey, authorizedParties, audience } = options || {}; + const req = incomingMessageToRequest(incomingMessage); const env = { ...loadApiEnv(), ...loadClientEnv() }; - - const isomorphicRequest = createIsomorphicRequest((Request, Headers) => { - const headers = Object.keys(req.headers).reduce((acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), {}); - - // @ts-ignore Optimistic attempt to get the protocol in case - // req extends IncomingMessage in a useful way. No guarantee - // it'll work. - const protocol = req.connection?.encrypted ? 'https' : 'http'; - const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://clerk-dummy`); - return new Request(dummyOriginReqUrl, { - method: req.method, - headers: new Headers(headers), - }); - }); - - const requestUrl = buildRequestUrl(isomorphicRequest); + const requestUrl = buildRequestUrl(req); const isSatellite = handleValueOrFn(options?.isSatellite, requestUrl, env.isSatellite); const domain = handleValueOrFn(options?.domain, requestUrl) || env.domain; const signInUrl = options?.signInUrl || env.signInUrl; @@ -77,7 +31,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { throw new Error(satelliteAndMissingSignInUrl); } - return clerkClient.authenticateRequest({ + return clerkClient.authenticateRequest(req, { audience, secretKey, publishableKey, @@ -87,23 +41,24 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { isSatellite, domain, signInUrl, - request: isomorphicRequest, + request: req, }); }; -export const handleUnknownCase = (res: ServerResponse, requestState: RequestState) => { - if (requestState.isUnknown) { - res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(); - } -}; -export const handleInterstitialCase = (res: ServerResponse, requestState: RequestState, interstitial: string) => { - if (requestState.isInterstitial) { - res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(interstitial); - } +const incomingMessageToRequest = (req: IncomingMessage): Request => { + const headers = Object.keys(req.headers).reduce((acc, key) => Object.assign(acc, { [key]: req?.headers[key] }), {}); + // @ts-ignore Optimistic attempt to get the protocol in case + // req extends IncomingMessage in a useful way. No guarantee + // it'll work. + const protocol = req.connection?.encrypted ? 'https' : 'http'; + const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://clerk-dummy`); + return new Request(dummyOriginReqUrl, { + method: req.method, + headers: new Headers(headers), + }); }; +// TODO: Move to backend export const decorateResponseWithObservabilityHeaders = (res: ServerResponse, requestState: RequestState) => { requestState.message && res.setHeader(constants.Headers.AuthMessage, encodeURIComponent(requestState.message)); requestState.reason && res.setHeader(constants.Headers.AuthReason, encodeURIComponent(requestState.reason)); diff --git a/packages/sdk-node/src/clerkClient.ts b/packages/sdk-node/src/clerkClient.ts index 657b1bb339a..7726e4dba7f 100644 --- a/packages/sdk-node/src/clerkClient.ts +++ b/packages/sdk-node/src/clerkClient.ts @@ -1,4 +1,4 @@ -import type { ClerkOptions, VerifyTokenOptions } from '@clerk/backend'; +import type { ClerkOptions } from '@clerk/backend'; import { createClerkClient, verifyToken as _verifyToken } from '@clerk/backend'; import { createClerkExpressRequireAuth } from './clerkExpressRequireAuth'; @@ -8,7 +8,7 @@ import { loadApiEnv, loadClientEnv } from './utils'; type ExtendedClerk = ReturnType & { expressWithAuth: ReturnType; expressRequireAuth: ReturnType; - verifyToken: (token: string, verifyOpts?: Parameters[1]) => ReturnType; + verifyToken: typeof verifyToken; }; /** @@ -20,9 +20,6 @@ export function Clerk(options: ClerkOptions): ExtendedClerk { const clerkClient = createClerkClient(options); const expressWithAuth = createClerkExpressWithAuth({ ...options, clerkClient }); const expressRequireAuth = createClerkExpressRequireAuth({ ...options, clerkClient }); - const verifyToken = (token: string, verifyOpts?: VerifyTokenOptions) => { - return _verifyToken(token, { ...options, ...verifyOpts }); - }; return Object.assign(clerkClient, { expressWithAuth, diff --git a/packages/sdk-node/src/clerkExpressRequireAuth.ts b/packages/sdk-node/src/clerkExpressRequireAuth.ts index 89632974c34..eb00450df75 100644 --- a/packages/sdk-node/src/clerkExpressRequireAuth.ts +++ b/packages/sdk-node/src/clerkExpressRequireAuth.ts @@ -1,12 +1,7 @@ +import { AuthStatus } from '@clerk/backend'; import type { createClerkClient } from '@clerk/backend'; -import { - authenticateRequest, - decorateResponseWithObservabilityHeaders, - handleInterstitialCase, - handleUnknownCase, - loadInterstitial, -} from './authenticateRequest'; +import { authenticateRequest, decorateResponseWithObservabilityHeaders } from './authenticateRequest'; import type { ClerkMiddlewareOptions, MiddlewareRequireAuthProp, RequireAuthProp } from './types'; export type CreateClerkExpressMiddlewareOptions = { @@ -29,22 +24,11 @@ export const createClerkExpressRequireAuth = (createOpts: CreateClerkExpressMidd options, }); decorateResponseWithObservabilityHeaders(res, requestState); - if (requestState.isUnknown) { - return handleUnknownCase(res, requestState); - } - if (requestState.isInterstitial) { - const interstitial = await loadInterstitial({ - clerkClient, - requestState, - }); - if (interstitial.errors) { - // Temporarily return Unauthenticated instead of the interstitial errors since we don't - // want to expose any internal error (possible errors are http 401, 500 response from BAPI) - // It will be dropped with the removal of fetching remotePrivateInterstitial - next(new Error('Unauthenticated')); - return; - } - return handleInterstitialCase(res, requestState, interstitial.data); + + if (requestState.status === AuthStatus.Handshake) { + // TODO: Handle handshake + // This needs to be refactored and reused by clerkExpressWithAuth as well + return; } if (requestState.isSignedIn) { diff --git a/packages/sdk-node/src/clerkExpressWithAuth.ts b/packages/sdk-node/src/clerkExpressWithAuth.ts index 08c22297a89..aba87f14eba 100644 --- a/packages/sdk-node/src/clerkExpressWithAuth.ts +++ b/packages/sdk-node/src/clerkExpressWithAuth.ts @@ -1,10 +1,6 @@ -import { - authenticateRequest, - decorateResponseWithObservabilityHeaders, - handleInterstitialCase, - handleUnknownCase, - loadInterstitial, -} from './authenticateRequest'; +import { AuthStatus } from '@clerk/backend'; + +import { authenticateRequest, decorateResponseWithObservabilityHeaders } from './authenticateRequest'; import type { CreateClerkExpressMiddlewareOptions } from './clerkExpressRequireAuth'; import type { ClerkMiddlewareOptions, MiddlewareWithAuthProp, WithAuthProp } from './types'; @@ -20,22 +16,11 @@ export const createClerkExpressWithAuth = (createOpts: CreateClerkExpressMiddlew options, }); decorateResponseWithObservabilityHeaders(res, requestState); - if (requestState.isUnknown) { - return handleUnknownCase(res, requestState); - } - if (requestState.isInterstitial) { - const interstitial = await loadInterstitial({ - clerkClient, - requestState, - }); - if (interstitial.errors) { - // Temporarily return Unauthenticated instead of the interstitial errors since we don't - // want to expose any internal error (possible errors are http 401, 500 response from BAPI) - // It will be dropped with the removal of fetching remotePrivateInterstitial - next(new Error('Unauthenticated')); - return; - } - return handleInterstitialCase(res, requestState, interstitial.data); + + if (requestState.status === AuthStatus.Handshake) { + // TODO: Handle handshake + // This needs to be refactored and reused by clerkExpressRequireAuth as well + return; } (req as WithAuthProp).auth = { diff --git a/packages/types/src/clerk.retheme.ts b/packages/types/src/clerk.retheme.ts index 0e4ae3c7c72..e58ce059064 100644 --- a/packages/types/src/clerk.retheme.ts +++ b/packages/types/src/clerk.retheme.ts @@ -532,12 +532,6 @@ export type ClerkOptions = ClerkOptionsNavigation & { afterSignInUrl?: string; afterSignUpUrl?: string; allowedRedirectOrigins?: Array; - - /** - * Indicates that clerk.js is will be loaded from interstitial - * Defaults to false - */ - isInterstitial?: boolean; isSatellite?: boolean | ((url: URL) => boolean); /** diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 78514012416..d18b3d26f58 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -526,12 +526,6 @@ export type ClerkOptions = ClerkOptionsNavigation & { afterSignInUrl?: string; afterSignUpUrl?: string; allowedRedirectOrigins?: Array; - - /** - * Indicates that clerk.js is will be loaded from interstitial - * Defaults to false - */ - isInterstitial?: boolean; isSatellite?: boolean | ((url: URL) => boolean); /** From 9b76632dcd946208b06048a1ac0b53d3b7decbb6 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 12 Dec 2023 17:49:32 +0200 Subject: [PATCH 14/19] chore(*): Fix linter --- packages/backend/src/index.ts | 1 - packages/backend/src/tokens/request.ts | 3 ++- .../src/core/devBrowserHandler.test.ts | 21 +------------------ packages/fastify/src/withClerkMiddleware.ts | 4 ++-- packages/sdk-node/src/authenticateRequest.ts | 1 - 5 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7b5de88c998..4b1bcafe9c5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,7 +6,6 @@ import type { CreateBackendApiOptions } from './api'; import { createBackendApiClient } from './api'; import type { CreateAuthenticateRequestOptions } from './tokens'; import { createAuthenticateRequest } from './tokens'; -import type { AuthenticateRequestOptions } from './tokens/request'; export { createIsomorphicRequest } from './util/IsomorphicRequest'; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 5025c91a670..fe2d81432fa 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,4 +1,4 @@ -import { isPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; +import { parsePublishableKey } from '@clerk/shared/keys'; import type { JwtPayload } from '@clerk/types'; import { constants } from '../constants'; @@ -12,6 +12,7 @@ import { TokenVerificationError, TokenVerificationErrorReason } from './errors'; import { verifyHandshakeToken } from './handshake'; import { decodeJwt } from './jwt'; import { verifyToken, type VerifyTokenOptions } from './verify'; + export type OptionalVerifyTokenOptions = Partial< Pick< VerifyTokenOptions, diff --git a/packages/clerk-js/src/core/devBrowserHandler.test.ts b/packages/clerk-js/src/core/devBrowserHandler.test.ts index d52059faec9..05ccba60f00 100644 --- a/packages/clerk-js/src/core/devBrowserHandler.test.ts +++ b/packages/clerk-js/src/core/devBrowserHandler.test.ts @@ -1,25 +1,6 @@ -import type { CreateDevBrowserHandlerOptions } from './devBrowserHandler'; -import { createDevBrowserHandler } from './devBrowserHandler'; - -describe('detBrowserHandler', () => { - // @ts-ignore - const { getDevBrowserJWT, setDevBrowserJWT, removeDevBrowserJWT } = createDevBrowserHandler( - {} as CreateDevBrowserHandlerOptions, - ); - +describe.skip('detBrowserHandler', () => { // TODO: Add devbrowser tests describe('get', () => { - beforeEach(() => { - Object.defineProperty(window, 'localStorage', { - value: { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - }, - writable: true, - }); - }); - it('todo', () => { expect(true).toBeTruthy(); }); diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 2b88bf1a6c9..c3fe1c1ade5 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,5 +1,5 @@ import { AuthStatus } from '@clerk/backend'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyRequest } from 'fastify'; import { clerkClient } from './clerkClient'; import * as constants from './constants'; @@ -7,7 +7,7 @@ import type { ClerkFastifyOptions } from './types'; import { fastifyRequestToRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { - return async (fastifyRequest: FastifyRequest, reply: FastifyReply) => { + return async (fastifyRequest: FastifyRequest) => { const secretKey = options.secretKey || constants.SECRET_KEY; const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; const req = fastifyRequestToRequest(fastifyRequest); diff --git a/packages/sdk-node/src/authenticateRequest.ts b/packages/sdk-node/src/authenticateRequest.ts index 81767bff8d0..17db4085c23 100644 --- a/packages/sdk-node/src/authenticateRequest.ts +++ b/packages/sdk-node/src/authenticateRequest.ts @@ -41,7 +41,6 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { isSatellite, domain, signInUrl, - request: req, }); }; From 7eed1b23c339f98b5dbadd5930080921972cd50e Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 12 Dec 2023 16:00:16 -0600 Subject: [PATCH 15/19] chore(backend): Remove unused AuthErrorReason properties, destructure from authenticateContext --- packages/backend/src/tokens/authStatus.ts | 23 ++------ packages/backend/src/tokens/handshake.ts | 2 +- packages/backend/src/tokens/request.test.ts | 6 +-- packages/backend/src/tokens/request.ts | 59 ++++++++++----------- 4 files changed, 38 insertions(+), 52 deletions(-) diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 9ad3a87069c..a44e67f3a94 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -53,28 +53,15 @@ export type HandshakeState = Omit & { }; export enum AuthErrorReason { + ClientUATWithoutSessionToken = 'client-uat-but-no-session-token', DevBrowserSync = 'dev-browser-sync', + PrimaryRespondsToSyncing = 'primary-responds-to-syncing', + SatelliteCookieNeedsSyncing = 'satellite-needs-syncing', + SessionTokenAndUATMissing = 'session-token-and-uat-missing', SessionTokenMissing = 'session-token-missing', - SessionTokenWithoutClientUAT = 'session-token-but-no-client-uat', - ClientUATWithoutSessionToken = 'client-uat-but-no-session-token', SessionTokenOutdated = 'session-token-outdated', - ClockSkew = 'clock-skew', + SessionTokenWithoutClientUAT = 'session-token-but-no-client-uat', UnexpectedError = 'unexpected-error', - Unknown = 'unknown', - - // Delete these old crap - CookieAndUATMissing = 'cookie-and-uat-missing', - CookieMissing = 'cookie-missing', - CookieOutDated = 'cookie-outdated', - CookieUATMissing = 'uat-missing', - CrossOriginReferrer = 'cross-origin-referrer', - HeaderMissingCORS = 'header-missing-cors', - HeaderMissingNonBrowser = 'header-missing-non-browser', - SatelliteCookieNeedsSyncing = 'satellite-needs-syncing', - SatelliteReturnsFromPrimary = 'satellite-returns-from-primary', - PrimaryRespondsToSyncing = 'primary-responds-to-syncing', - StandardSignedIn = 'standard-signed-in', - StandardSignedOut = 'standard-signed-out', } export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index c0b57bfbd4f..5c867e4a804 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -4,7 +4,7 @@ import { assertHeaderAlgorithm, assertHeaderType } from './jwt/assertions'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; import type { VerifyTokenOptions } from './verify'; -export async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { +async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { const decoded = decodeJwt(token); const { header, payload } = decoded; diff --git a/packages/backend/src/tokens/request.test.ts b/packages/backend/src/tokens/request.test.ts index ab673cf2ca8..66dc24b3e36 100644 --- a/packages/backend/src/tokens/request.test.ts +++ b/packages/backend/src/tokens/request.test.ts @@ -316,7 +316,7 @@ export default (QUnit: QUnit) => { ); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.CookieAndUATMissing, + reason: AuthErrorReason.SessionTokenAndUATMissing, isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', @@ -374,7 +374,7 @@ export default (QUnit: QUnit) => { ); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.CookieAndUATMissing, + reason: AuthErrorReason.SessionTokenAndUATMissing, }); assertSignedOutToAuth(assert, requestState); }); @@ -434,7 +434,7 @@ export default (QUnit: QUnit) => { const requestState = await authenticateRequest(mockRequestWithCookies({}, { __client_uat: '0' }), mockOptions()); assertSignedOut(assert, requestState, { - reason: AuthErrorReason.CookieAndUATMissing, + reason: AuthErrorReason.SessionTokenAndUATMissing, }); assertSignedOutToAuth(assert, requestState); }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index fe2d81432fa..e84e12fc4a2 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -91,6 +91,8 @@ export async function authenticateRequest( } async function resolveHandshake() { + const { derivedRequestUrl } = authenticateContext; + const headers = new Headers({ 'Access-Control-Allow-Origin': 'null', 'Access-Control-Allow-Credentials': 'true', @@ -108,7 +110,7 @@ export async function authenticateRequest( }); if (instanceType === 'development') { - const newUrl = new URL(authenticateContext.derivedRequestUrl); + const newUrl = new URL(derivedRequestUrl); newUrl.searchParams.delete('__clerk_handshake'); newUrl.searchParams.delete('__clerk_help'); headers.append('Location', newUrl.toString()); @@ -161,8 +163,10 @@ ${err.getFullMessage()}`, const instanceType = pk.instanceType; async function authenticateRequestWithTokenInHeader() { + const { sessionTokenInHeader } = authenticateContext; + try { - const verifyResult = await verifyToken(authenticateContext.sessionTokenInHeader!, authenticateContext); + const verifyResult = await verifyToken(sessionTokenInHeader!, authenticateContext); return await signedIn(options, verifyResult); } catch (err) { return handleError(err, 'header'); @@ -170,11 +174,24 @@ ${err.getFullMessage()}`, } async function authenticateRequestWithTokenInCookie() { - const clientUat = parseInt(authenticateContext.clientUat || '', 10) || 0; + const { + derivedRequestUrl, + isSatellite, + secFetchDest, + signInUrl, + clientUat: clientUatRaw, + sessionTokenInCookie: sessionToken, + } = authenticateContext; + + const clientUat = parseInt(clientUatRaw || '', 10) || 0; const hasActiveClient = clientUat > 0; - const sessionToken = authenticateContext.sessionTokenInCookie; const hasSessionToken = !!sessionToken; + const isRequestEligibleForMultiDomainSync = + isSatellite && + secFetchDest === 'document' && + !derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced); + /** * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. */ @@ -185,10 +202,7 @@ ${err.getFullMessage()}`, /** * Otherwise, check for "known unknown" auth states that we can resolve with a handshake. */ - if ( - instanceType === 'development' && - authenticateContext.derivedRequestUrl.searchParams.has(constants.Cookies.DevBrowser) - ) { + if (instanceType === 'development' && derivedRequestUrl.searchParams.has(constants.Cookies.DevBrowser)) { const headers = buildRedirectToHandshake(); return handshake(authenticateContext, AuthErrorReason.DevBrowserSync, '', headers); } @@ -196,41 +210,26 @@ ${err.getFullMessage()}`, /** * Begin multi-domain sync flows */ - if ( - instanceType === 'production' && - authenticateContext.isSatellite && - authenticateContext.secFetchDest === 'document' && - !authenticateContext.derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced) - ) { + if (instanceType === 'production' && isRequestEligibleForMultiDomainSync) { const headers = buildRedirectToHandshake(); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } // Multi-domain development sync flow - if ( - instanceType === 'development' && - authenticateContext.isSatellite && - authenticateContext.secFetchDest === 'document' && - !authenticateContext.derivedRequestUrl.searchParams.has(constants.QueryParameters.ClerkSynced) - ) { + if (instanceType === 'development' && isRequestEligibleForMultiDomainSync) { // initiate MD sync // signInUrl exists, checked at the top of `authenticateRequest` - const redirectURL = new URL(authenticateContext.signInUrl!); - redirectURL.searchParams.append( - constants.QueryParameters.ClerkRedirectUrl, - authenticateContext.derivedRequestUrl.toString(), - ); + const redirectURL = new URL(signInUrl!); + redirectURL.searchParams.append(constants.QueryParameters.ClerkRedirectUrl, derivedRequestUrl.toString()); const headers = new Headers({ location: redirectURL.toString() }); return handshake(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); } // Multi-domain development sync flow - const redirectUrl = new URL(authenticateContext.derivedRequestUrl).searchParams.get( - constants.QueryParameters.ClerkRedirectUrl, - ); - if (instanceType === 'development' && !authenticateContext.isSatellite && redirectUrl) { + const redirectUrl = new URL(derivedRequestUrl).searchParams.get(constants.QueryParameters.ClerkRedirectUrl); + if (instanceType === 'development' && !isSatellite && redirectUrl) { // Dev MD sync from primary, redirect back to satellite w/ __clerk_db_jwt const redirectBackToSatelliteUrl = new URL(redirectUrl); @@ -247,7 +246,7 @@ ${err.getFullMessage()}`, */ if (!hasActiveClient && !hasSessionToken) { - return signedOut(authenticateContext, AuthErrorReason.CookieAndUATMissing); + return signedOut(authenticateContext, AuthErrorReason.SessionTokenAndUATMissing); } // This can eagerly run handshake since client_uat is SameSite=Strict in dev From 91404bcf7b917fa5e6c32887e9a0cc96eaf686a4 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 12 Dec 2023 16:31:09 -0600 Subject: [PATCH 16/19] chore(clerk-js): Remove unused @ts-expect-error directive --- packages/clerk-js/src/core/clerk.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 16b91f94aae..f831e805d07 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1177,7 +1177,6 @@ export class Clerk implements ClerkInterface { } #hasJustSynced = () => getClerkQueryParam(CLERK_SYNCED) === 'true'; - // @ts-expect-error @nikos #clearJustSynced = () => removeClerkQueryParam(CLERK_SYNCED); #buildSyncUrlForDevelopmentInstances = (): string => { From 59943a53bcbc658f06669c7a7010a0e07082a970 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 13 Dec 2023 10:59:10 +0200 Subject: [PATCH 17/19] fix: Address build issues in sdk-node --- packages/sdk-node/src/clerkClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdk-node/src/clerkClient.ts b/packages/sdk-node/src/clerkClient.ts index 7726e4dba7f..eed18896e94 100644 --- a/packages/sdk-node/src/clerkClient.ts +++ b/packages/sdk-node/src/clerkClient.ts @@ -1,11 +1,11 @@ import type { ClerkOptions } from '@clerk/backend'; -import { createClerkClient, verifyToken as _verifyToken } from '@clerk/backend'; +import { createClerkClient, verifyToken } from '@clerk/backend'; import { createClerkExpressRequireAuth } from './clerkExpressRequireAuth'; import { createClerkExpressWithAuth } from './clerkExpressWithAuth'; import { loadApiEnv, loadClientEnv } from './utils'; -type ExtendedClerk = ReturnType & { +type ClerkClient = ReturnType & { expressWithAuth: ReturnType; expressRequireAuth: ReturnType; verifyToken: typeof verifyToken; @@ -16,7 +16,7 @@ type ExtendedClerk = ReturnType & { * new Clerk() syntax for v4 compatibility. * Arrow functions can never be called with the new keyword because they do not have the [[Construct]] method */ -export function Clerk(options: ClerkOptions): ExtendedClerk { +export function Clerk(options: ClerkOptions): ClerkClient { const clerkClient = createClerkClient(options); const expressWithAuth = createClerkExpressWithAuth({ ...options, clerkClient }); const expressRequireAuth = createClerkExpressRequireAuth({ ...options, clerkClient }); From e4b8cf1746f99adcc5886a6cbdb0073bdb1c374e Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 13 Dec 2023 11:08:14 +0200 Subject: [PATCH 18/19] fix(remix): Correct Remix build issues --- packages/remix/src/ssr/authenticateRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix/src/ssr/authenticateRequest.ts b/packages/remix/src/ssr/authenticateRequest.ts index fe63f59ee10..51d203da2e1 100644 --- a/packages/remix/src/ssr/authenticateRequest.ts +++ b/packages/remix/src/ssr/authenticateRequest.ts @@ -70,7 +70,7 @@ export function authenticateRequest(args: LoaderFunctionArgs, opts: RootAuthLoad throw new Error(satelliteAndMissingSignInUrl); } - return createClerkClient({ apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain }).authenticateRequest({ + return createClerkClient({ apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain }).authenticateRequest(request, { audience, secretKey, jwtKey, From fb6e5c10969e689571bed3f792b80dc1e102e58f Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 13 Dec 2023 11:17:30 +0200 Subject: [PATCH 19/19] chore(repo): Apply linting fixes --- packages/chrome-extension/src/index.ts | 3 +-- packages/clerk-js/src/core/resources/Organization.ts | 10 +++++----- .../src/core/resources/OrganizationSuggestion.ts | 2 +- .../src/core/resources/UserOrganizationInvitation.ts | 2 +- packages/remix/src/client/RemixClerkProvider.tsx | 2 -- packages/sdk-node/src/clerkExpressRequireAuth.ts | 2 +- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/chrome-extension/src/index.ts b/packages/chrome-extension/src/index.ts index e16785a2c57..58facee8907 100644 --- a/packages/chrome-extension/src/index.ts +++ b/packages/chrome-extension/src/index.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line import/export export * from '@clerk/clerk-react'; // order matters since we want override @clerk/clerk-react ClerkProvider -// eslint-disable-next-line import/export + export { ClerkProvider } from './ClerkProvider'; diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 415cbebeda6..4ab04ab01c0 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -87,7 +87,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/roles`, method: 'GET', - search: convertPageToOffset(getRolesParams) as any, + search: convertPageToOffset(getRolesParams), }, { forceUpdateClient: true, @@ -109,7 +109,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/domains`, method: 'GET', - search: convertPageToOffset(getDomainParams) as any, + search: convertPageToOffset(getDomainParams), }, { forceUpdateClient: true, @@ -146,7 +146,7 @@ export class Organization extends BaseResource implements OrganizationResource { return await BaseResource._fetch({ path: `/organizations/${this.id}/membership_requests`, method: 'GET', - search: convertPageToOffset(getRequestParam) as any, + search: convertPageToOffset(getRequestParam), }) .then(res => { const { data: requests, total_count } = @@ -173,7 +173,7 @@ export class Organization extends BaseResource implements OrganizationResource { method: 'GET', // `paginated` is used in some legacy endpoints to support clerk paginated responses // The parameter will be dropped in FAPI v2 - search: convertPageToOffset({ ...getMembershipsParams, paginated: true }) as any, + search: convertPageToOffset({ ...getMembershipsParams, paginated: true }), }) .then(res => { const { data: suggestions, total_count } = @@ -199,7 +199,7 @@ export class Organization extends BaseResource implements OrganizationResource { { path: `/organizations/${this.id}/invitations`, method: 'GET', - search: convertPageToOffset(getInvitationsParams) as any, + search: convertPageToOffset(getInvitationsParams), }, { forceUpdateClient: true, diff --git a/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts index 879ba46719f..41e31855d56 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts @@ -29,7 +29,7 @@ export class OrganizationSuggestion extends BaseResource implements Organization return await BaseResource._fetch({ path: '/me/organization_suggestions', method: 'GET', - search: convertPageToOffset(params) as any, + search: convertPageToOffset(params), }) .then(res => { const { data: suggestions, total_count } = diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 40e4fa7fc06..074a5af4d8d 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -27,7 +27,7 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga return await BaseResource._fetch({ path: '/me/organization_invitations', method: 'GET', - search: convertPageToOffset(params) as any, + search: convertPageToOffset(params), }) .then(res => { const { data: invites, total_count } = diff --git a/packages/remix/src/client/RemixClerkProvider.tsx b/packages/remix/src/client/RemixClerkProvider.tsx index effceea1283..2b87492996d 100644 --- a/packages/remix/src/client/RemixClerkProvider.tsx +++ b/packages/remix/src/client/RemixClerkProvider.tsx @@ -7,7 +7,6 @@ import { ClerkRemixOptionsProvider } from './RemixOptionsContext'; import type { ClerkState } from './types'; import { useAwaitableNavigate } from './useAwaitableNavigate'; -// eslint-disable-next-line import/export export * from '@clerk/clerk-react'; const SDK_METADATA = { @@ -32,7 +31,6 @@ export type RemixClerkProviderProps = { */ const awaitableNavigateRef: { current: ReturnType | undefined } = { current: undefined }; -// eslint-disable-next-line import/export export function ClerkProvider({ children, ...rest }: RemixClerkProviderProps): JSX.Element { const awaitableNavigate = useAwaitableNavigate(); diff --git a/packages/sdk-node/src/clerkExpressRequireAuth.ts b/packages/sdk-node/src/clerkExpressRequireAuth.ts index eb00450df75..47f3eef0810 100644 --- a/packages/sdk-node/src/clerkExpressRequireAuth.ts +++ b/packages/sdk-node/src/clerkExpressRequireAuth.ts @@ -1,5 +1,5 @@ -import { AuthStatus } from '@clerk/backend'; import type { createClerkClient } from '@clerk/backend'; +import { AuthStatus } from '@clerk/backend'; import { authenticateRequest, decorateResponseWithObservabilityHeaders } from './authenticateRequest'; import type { ClerkMiddlewareOptions, MiddlewareRequireAuthProp, RequireAuthProp } from './types';