diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f706fb7e37..203012b194 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -28,7 +28,7 @@ export type ClerkOptions = CreateBackendApiOptions & export function Clerk(options: ClerkOptions) { const opts = { ...options }; const apiClient = createBackendApiClient(opts); - const requestState = createAuthenticateRequest({ options: opts, apiClient }); + const requestState = createAuthenticateRequest({ options: opts }); const telemetry = new TelemetryCollector({ ...options.telemetry, publishableKey: opts.publishableKey, diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 401113e61b..41ed953d4f 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -8,8 +8,8 @@ import type { TokenVerificationErrorReason } from './errors'; export enum AuthStatus { SignedIn = 'signed-in', SignedOut = 'signed-out', - Interstitial = 'interstitial', - Unknown = 'unknown', + // will not be used + Handshake = 'handshake', } export type SignedInState = { @@ -24,9 +24,6 @@ export type SignedInState = { signUpUrl: string; afterSignInUrl: string; afterSignUpUrl: string; - isSignedIn: true; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedInAuthObject; }; @@ -42,24 +39,14 @@ export type SignedOutState = { signUpUrl: string; afterSignInUrl: string; afterSignUpUrl: string; - isSignedIn: false; - isInterstitial: false; - isUnknown: false; toAuth: () => SignedOutAuthObject; }; -export type InterstitialState = Omit & { - status: AuthStatus.Interstitial; - isInterstitial: true; +export type HandshakeState = Omit & { + status: AuthStatus.Handshake; toAuth: () => null; }; -export type UnknownState = Omit & { - status: AuthStatus.Unknown; - isInterstitial: false; - isUnknown: true; -}; - export enum AuthErrorReason { CookieAndUATMissing = 'cookie-and-uat-missing', CookieMissing = 'cookie-missing', @@ -79,7 +66,7 @@ export enum AuthErrorReason { export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; -export type RequestState = SignedInState | SignedOutState | InterstitialState | UnknownState; +export type RequestState = SignedInState | SignedOutState | HandshakeState; type LoadResourcesOptions = { loadSession?: boolean; @@ -179,12 +166,10 @@ export async function signedIn( signUpUrl, afterSignInUrl, afterSignUpUrl, - isSignedIn: true, - isInterstitial: false, - isUnknown: false, toAuth: () => authObject, }; } + export function signedOut( options: T, reason: AuthReason, @@ -213,49 +198,15 @@ export function signedOut( signUpUrl, afterSignInUrl, afterSignUpUrl, - isSignedIn: false, - isInterstitial: false, - isUnknown: false, toAuth: () => signedOutAuthObject({ ...options, status: AuthStatus.SignedOut, reason, message }), }; } -export function interstitial( +export function handshake( 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, - }; -} - -export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, message = ''): UnknownState { +): HandshakeState { const { publishableKey = '', proxyUrl = '', @@ -268,7 +219,7 @@ export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, } = options; return { - status: AuthStatus.Unknown, + status: AuthStatus.Handshake, reason, message, publishableKey, @@ -279,9 +230,6 @@ export function unknownState(options: AuthStatusOptionsType, reason: AuthReason, signUpUrl, afterSignInUrl, afterSignUpUrl, - isSignedIn: false, - isInterstitial: false, - isUnknown: true, toAuth: () => null, }; } diff --git a/packages/backend/src/tokens/errors.ts b/packages/backend/src/tokens/errors.ts index 8c3f8a0249..ffe84c3973 100644 --- a/packages/backend/src/tokens/errors.ts +++ b/packages/backend/src/tokens/errors.ts @@ -22,8 +22,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 f24821ad63..2051f60768 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -1,7 +1,4 @@ -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'; @@ -36,11 +33,9 @@ const defaultOptions = { export type CreateAuthenticateRequestOptions = { options: BuildTimeOptions; - apiClient: ApiClient; }; export function createAuthenticateRequest(params: CreateAuthenticateRequestOptions) { - const { apiClient } = params; const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); const authenticateRequest = (options: RunTimeOptions) => { @@ -56,18 +51,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/handshakeRule.ts b/packages/backend/src/tokens/handshakeRule.ts new file mode 100644 index 0000000000..272789de5b --- /dev/null +++ b/packages/backend/src/tokens/handshakeRule.ts @@ -0,0 +1,233 @@ +import { parse as parseCookies } from 'cookie'; + +import type { AuthStatusOptionsType, RequestState } from './authStatus'; +import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; +import { verifyToken } from './verify'; + +export type HandshakeRulesOptions = AuthStatusOptionsType & { + /* Request object */ + request: Request; + /* 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; + /* Temp handshake token to make demo work */ + handshakeToken?: string; +}; + +type RuleResult = RequestState | undefined; +type HandshakeRule = (opts: HandshakeRulesOptions) => Promise | RuleResult; + +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. +// const nonBrowserRequestInDevRule: HandshakeRule = options => { +// const { secretKey, userAgent } = options; +// if (isDevelopmentFromSecretKey(secretKey || '') && !isBrowser(userAgent)) { +// return signedOut(options, AuthErrorReason.HeaderMissingNonBrowser); +// } +// return undefined; +// }; + +// const crossOriginRequestWithoutHeader: HandshakeRule = 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; +// }; +// +// const isPrimaryInDevAndRedirectsToSatellite: HandshakeRule = options => { +// const { secretKey = '', isSatellite, searchParams } = options; +// const isDev = isDevelopmentFromSecretKey(secretKey); +// +// if (isDev && !isSatellite && shouldRedirectToSatelliteUrl(searchParams)) { +// return interstitial(options, AuthErrorReason.PrimaryRespondsToSyncing); +// } +// return undefined; +// }; +// +// const potentialFirstLoadInDevWhenUATMissing: HandshakeRule = 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. +// */ +// const potentialRequestAfterSignInOrOutFromClerkHostedUiInDev: HandshakeRule = 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; +// }; +// +// const potentialFirstRequestOnProductionEnvironment: HandshakeRule = 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 }; +// } +// }; +const isNormalSignedOutState: HandshakeRule = 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. +const hasPositiveClientUatButCookieIsMissing: HandshakeRule = options => { + const { clientUat, cookieToken } = options; + + if (clientUat && Number.parseInt(clientUat) > 0 && !cookieToken) { + return handshake(options, AuthErrorReason.CookieMissing); + } + return undefined; +}; + +const hasComeBackFromHandshakeEndpoint: HandshakeRule = options => { + const { request } = options; + const reqUrl = new URL(request.url); + const COOKIE_NAME = '__clerk_handshake'; + const cookies = parseCookies(request.headers.get('cookie') || ''); + const clerkHandshakeToken = cookies[COOKIE_NAME] || reqUrl.searchParams.get('__clerk_handshake'); + if (clerkHandshakeToken) { + return handshake(options, AuthErrorReason.CookieMissing); + } + return undefined; +}; + +const hasValidHeaderToken: HandshakeRule = async options => { + const { headerToken } = options; + const sessionClaims = await verifyRequestState(options, headerToken as string); + return await signedIn(options, sessionClaims); +}; + +const hasValidCookieToken: HandshakeRule = async options => { + const { cookieToken, clientUat, handshakeToken } = options; + // TODO: Remove handshakeToken + console.log('hasValidCookieToken', { handshakeToken }); + const sessionClaims = await verifyRequestState(options, handshakeToken || (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 handshake(options, AuthErrorReason.CookieOutDated); + } + + return state; +}; + +async function runHandshakeRules( + opts: T, + rules: HandshakeRule[], +): Promise { + for (const rule of rules) { + const res = await rule(opts); + if (res) { + return res; + } + } + + return signedOut(opts, AuthErrorReason.UnexpectedError); +} + +async function verifyRequestState(options: HandshakeRulesOptions, 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 }); +} + +/** + * Avoid throwing this rule for development instances + * Let the next rule for UatMissing to fire if needed + */ +// const isSatelliteAndNeedsSyncing: HandshakeRule = 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; +// }; + +const rules = { + hasValidCookieToken, + hasValidHeaderToken, + hasPositiveClientUatButCookieIsMissing, + isNormalSignedOutState, + hasComeBackFromHandshakeEndpoint, +}; + +export { runHandshakeRules, rules }; diff --git a/packages/backend/src/tokens/index.ts b/packages/backend/src/tokens/index.ts index 45963f9f6c..a9d6cf1f63 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 * 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 2e9b444d29..0000000000 --- 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 cfc9267008..0000000000 --- a/packages/backend/src/tokens/interstitialRule.ts +++ /dev/null @@ -1,205 +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; -}; - -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) { - 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 }); -} - -/** - * 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.ts b/packages/backend/src/tokens/request.ts index bb61419175..cf675db5b0 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -3,25 +3,13 @@ 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, signedOut } 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 { HandshakeRulesOptions } from './handshakeRule'; +import { rules, runHandshakeRules } from './handshakeRule'; import type { VerifyTokenOptions } from './verify'; + export type OptionalVerifyTokenOptions = Partial< Pick< VerifyTokenOptions, @@ -29,7 +17,11 @@ export type OptionalVerifyTokenOptions = Partial< > >; -export type AuthenticateRequestOptions = AuthStatusOptionsType & OptionalVerifyTokenOptions & { request: Request }; +export type AuthenticateRequestOptions = AuthStatusOptionsType & + OptionalVerifyTokenOptions & { + request: Request; + handshakeToken?: string; + }; function assertSignInUrlExists(signInUrl: string | undefined, key: string): asserts signInUrl is string { if (!signInUrl && isDevelopmentFromSecretKey(key)) { @@ -63,8 +55,9 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): ...options, ...loadOptionsFromHeaders(headers), ...loadOptionsFromCookies(cookies), + request: options.request, searchParams, - } satisfies InterstitialRuleOptions; + } satisfies HandshakeRulesOptions; assertValidSecretKey(ruleOptions.secretKey); @@ -76,10 +69,27 @@ export async function authenticateRequest(options: AuthenticateRequestOptions): assertProxyUrlOrDomain(ruleOptions.proxyUrl || ruleOptions.domain); } + function handleError(err: unknown, tokenCarrier: TokenCarrier) { + if (!(err instanceof TokenVerificationError)) { + return signedOut(ruleOptions, AuthErrorReason.UnexpectedError, (err as Error).message); + } + + err.tokenCarrier = tokenCarrier; + const recoverableErrors = [ + TokenVerificationErrorReason.TokenExpired, + TokenVerificationErrorReason.TokenNotActiveYet, + ].includes(err.reason); + + if (!recoverableErrors || tokenCarrier === 'header') { + return signedOut(ruleOptions, err.reason, err.getFullMessage()); + } + + return handshake(ruleOptions, err.reason, err.getFullMessage()); + } + async function authenticateRequestWithTokenInHeader() { try { - const state = await runInterstitialRules(ruleOptions, [hasValidHeaderToken]); - return state; + return await runHandshakeRules(ruleOptions, [rules.hasValidHeaderToken]); } catch (err) { return handleError(err, 'header'); } @@ -87,58 +97,20 @@ 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, - ]); - - return state; + return await runHandshakeRules(ruleOptions, [rules.hasValidCookieToken]); } catch (err) { return handleError(err, 'cookie'); } } - function handleError(err: unknown, tokenCarrier: TokenCarrier) { - if (err instanceof TokenVerificationError) { - err.tokenCarrier = tokenCarrier; - - const reasonToReturnInterstitial = [ - TokenVerificationErrorReason.TokenExpired, - TokenVerificationErrorReason.TokenNotActiveYet, - ].includes(err.reason); - - if (reasonToReturnInterstitial) { - if (tokenCarrier === 'header') { - return unknownState(ruleOptions, err.reason, err.getFullMessage()); - } - return interstitial(ruleOptions, err.reason, err.getFullMessage()); - } - return signedOut(ruleOptions, err.reason, err.getFullMessage()); - } - return signedOut(ruleOptions, AuthErrorReason.UnexpectedError, (err as Error).message); - } - - if (ruleOptions.headerToken) { - return authenticateRequestWithTokenInHeader(); - } - return authenticateRequestWithTokenInCookie(); + return ruleOptions.headerToken ? authenticateRequestWithTokenInHeader() : 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 }; + const { proxyUrl, reason, message, publishableKey, isSatellite, domain, status } = params; + return { proxyUrl, reason, message, publishableKey, isSatellite, domain, status }; }; -export type DebugRequestSate = ReturnType; - /** * Load authenticate request options related to headers. */ diff --git a/packages/backend/src/util/path.ts b/packages/backend/src/util/path.ts index 708288bc14..84749212d2 100644 --- a/packages/backend/src/util/path.ts +++ b/packages/backend/src/util/path.ts @@ -1,11 +1,2 @@ -const SEPARATOR = '/'; -const MULTIPLE_SEPARATOR_REGEX = new RegExp(SEPARATOR + '{1,}', 'g'); - -type PathString = string | null | undefined; - -export function joinPaths(...args: PathString[]): string { - return args - .filter(p => p) - .join(SEPARATOR) - .replace(MULTIPLE_SEPARATOR_REGEX, SEPARATOR); -} +// TODO: @nikos refactor this to use the shared package directly +export { joinPaths } from '@clerk/shared/url'; diff --git a/packages/react/src/utils/loadClerkJsScript.ts b/packages/react/src/utils/loadClerkJsScript.ts index c6dab5f282..4a18a3987b 100644 --- a/packages/react/src/utils/loadClerkJsScript.ts +++ b/packages/react/src/utils/loadClerkJsScript.ts @@ -39,6 +39,7 @@ const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => { } let scriptHost = ''; + // TODO @nikos: this should be refactored into a single utility if (!!proxyUrl && isValidProxyUrl(proxyUrl)) { scriptHost = proxyUrlToAbsoluteURL(proxyUrl).replace(/http(s)?:\/\//, ''); } else if (domain && !isDevOrStagingUrl(parsePublishableKey(publishableKey)?.frontendApi || '')) { diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts index a72ea2eafd..9ae3f11415 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -79,3 +79,15 @@ export function isCurrentDevAccountPortalOrigin(host: string): boolean { return host.endsWith(currentDevSuffix) && !host.endsWith('.clerk' + currentDevSuffix); }); } + +const SEPARATOR = '/'; +const MULTIPLE_SEPARATOR_REGEX = new RegExp(SEPARATOR + '{1,}', 'g'); + +type PathString = string | null | undefined; + +export function joinPaths(...args: PathString[]): string { + return args + .filter(p => p) + .join(SEPARATOR) + .replace(MULTIPLE_SEPARATOR_REGEX, SEPARATOR); +}