diff --git a/packages/backend/src/api/endpoints/InterstitialApi.ts b/packages/backend/src/api/endpoints/InterstitialApi.ts deleted file mode 100644 index 2ce6cc967c..0000000000 --- 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 b0098db07c..0d7f31061e 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 './OrganizationRoleApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 4da11471a5..38257041bc 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, OrganizationPermissionAPI, @@ -28,7 +27,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), organizationRoles: new OrganizationRoleAPI(request), 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/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 53ce94693d..ba033df3f3 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -1,24 +1,20 @@ -import type { AuthObject, RequestState } from '@clerk/backend'; -import { buildRequestUrl, constants, TokenVerificationErrorReason } from '@clerk/backend'; +import type { AuthObject } from '@clerk/backend'; +import { buildRequestUrl, constants, verifyToken } 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'; import type { Autocomplete } from '@clerk/types'; import type Link from 'next/link'; -import type { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; +import type { NextFetchEvent, NextMiddleware } from 'next/server'; +import { NextRequest, 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 { authenticateRequestHandshake, startHandshake } from './handshake'; import { redirectToSignIn } from './redirect'; import type { NextMiddlewareResult, WithAuthOptions } from './types'; import { isDevAccountPortalOrigin } from './url'; @@ -39,8 +35,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,43 +185,85 @@ 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); + // handle handshake + const handshakeRes = await authenticateRequestHandshake(req, options); + console.log('skt 1 authenticateRequestHandshake', handshakeRes, options); + if (handshakeRes.status === 'handshake') { + return startHandshake(req, options); + } - assertClockSkew(requestState, options); + let reqWithCookie = req; + // If we have a handshake token here it means we just got back from + // the handshake redirect. Set it on the req temporarily so that we can + // reuse the existing mechanisms. Pending refactor. + if (handshakeRes.handshakeToken) { + req.cookies.delete('__session'); + const oldCookiesString = req.cookies.toString(); + const newCookiesString = `${oldCookiesString}; __session=${handshakeRes.handshakeToken}`; + reqWithCookie = withNormalizedClerkUrl( + new NextRequest(req.url, { ...req, headers: { ...req.headers, cookie: newCookiesString } }), + ); + } + console.log('skt 2.1 reqWithCookie', reqWithCookie.headers.get('cookie')); + // todo: handle normal signed in or signed out + const requestState = await authenticateRequest(reqWithCookie, options); - const res = handleInterstitialState(requestState, options); - return assertInfiniteRedirectionLoop(req, res, options, requestState); + if (requestState.status === 'handshake') { + console.log(requestState); + throw new Error('invalid state, should be signed in or signed out at this point'); } + // signed in or signed out const auth = Object.assign(requestState.toAuth(), { - isPublicRoute: isPublicRoute(req), - isApiRoute: isApiRoute(req), + isPublicRoute: isPublicRoute(reqWithCookie), + isApiRoute: isApiRoute(reqWithCookie), }); + + console.log('skt 3 signed in or signed out', auth.sessionId); logger.debug(() => ({ auth: JSON.stringify(auth), debug: auth.debug() })); - const afterAuthRes = await (afterAuth || defaultAfterAuth)(auth, req, evt); - const finalRes = mergeResponses(beforeAuthRes, afterAuthRes) || NextResponse.next(); + const afterAuthRes = await (afterAuth || defaultAfterAuth)(auth, reqWithCookie, evt); + const resWithHeadersFromHandshake = new NextResponse(null, { headers: handshakeRes.headers }); + + // Remove the handshake token from the search params if it exists. + // Otherwise, refresh the page after 1 minute will result in a stale token and error + // as we check whether a handshake token exists in order to complete the handshake flow + const shouldRemoveHandshakeFromSearchParams = !!reqWithCookie.nextUrl.searchParams.get('__clerk_handshake'); + const urlWithoutHandshake = new URL(reqWithCookie.nextUrl); + urlWithoutHandshake.searchParams.delete('__clerk_handshake'); + urlWithoutHandshake.searchParams.delete('__clerk_help'); + + const finalRes = + mergeResponses( + beforeAuthRes, + afterAuthRes, + resWithHeadersFromHandshake, + shouldRemoveHandshakeFromSearchParams + ? new NextResponse(null, { + status: 307, + headers: new Headers({ Location: urlWithoutHandshake.toString() }), + }) + : null, + ) || NextResponse.next(); + + console.log('skt 3.1 ', { + headerstoset: handshakeRes.headers && [...handshakeRes.headers.entries()], + finalHeaders: [...finalRes.headers.entries()], + }); + logger.debug(() => ({ mergedHeaders: stringifyHeaders(finalRes.headers) })); if (isRedirect(finalRes)) { logger.debug('Final response is redirect, following redirect'); const res = setHeader(finalRes, constants.Headers.AuthReason, 'redirect'); - return appendDevBrowserOnCrossOrigin(req, res, options); + return appendDevBrowserOnCrossOrigin(reqWithCookie, res, options); } if (options.debug) { - setRequestHeadersOnNextResponse(finalRes, req, { [constants.Headers.EnableDebug]: 'true' }); + setRequestHeadersOnNextResponse(finalRes, reqWithCookie, { [constants.Headers.EnableDebug]: 'true' }); logger.debug(`Added ${constants.Headers.EnableDebug} on request`); } - return decorateRequest(req, finalRes, requestState); + return decorateRequest(reqWithCookie, finalRes, requestState); }); }; @@ -352,55 +388,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 8d995fb7d2..9fab01022b 100644 --- a/packages/nextjs/src/server/authenticateRequest.ts +++ b/packages/nextjs/src/server/authenticateRequest.ts @@ -1,14 +1,13 @@ -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 { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; import type { WithAuthOptions } from './types'; -import { apiEndpointUnauthorizedNextResponse, handleMultiDomainAndProxy } from './utils'; +import { handleMultiDomainAndProxy } from './utils'; -export const authenticateRequest = async (req: NextRequest, opts: WithAuthOptions) => { +export const authenticateRequest = async ( + req: Request, + opts: WithAuthOptions, + // handshakeToken: string | undefined, +) => { const { isSatellite, domain, signInUrl, proxyUrl } = handleMultiDomainAndProxy(req, opts); return await clerkClient.authenticateRequest({ ...opts, @@ -19,40 +18,12 @@ export const authenticateRequest = async (req: NextRequest, opts: WithAuthOption signInUrl, proxyUrl, request: req, + // handshakeToken, }); }; -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: WithAuthOptions) => { - 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; -}; +// 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)); +// }; diff --git a/packages/nextjs/src/server/clerk-request.ts b/packages/nextjs/src/server/clerk-request.ts new file mode 100644 index 0000000000..9b75fce1ca --- /dev/null +++ b/packages/nextjs/src/server/clerk-request.ts @@ -0,0 +1,30 @@ +import { parse as parseCookies } from 'cookie'; + +class ClerkRequest extends Request { + clerkUrl: URL; + cookies: Map; + + constructor(req: Request) { + super(req); + this.clerkUrl = this.#deriveUrlFromHeaders(req); + this.cookies = new Map(Object.entries(parseCookies(req.headers.get('cookie') || ''))); + } + + #deriveUrlFromHeaders = (req: Request) => { + const reqUrl = new URL(req.url); + const protocol = req.headers.get('x-forwarded-proto') || 'http'; + const host = req.headers.get('x-forwarded-host') || reqUrl.host; + const res = new URL(`${protocol}://${host}`); + res.pathname = reqUrl.pathname; + reqUrl.searchParams.forEach((value, key) => { + res.searchParams.append(key, value); + }); + return res; + }; +} + +export const createClerkRequest = (req: Request): ClerkRequest => { + return new ClerkRequest(req); +}; + +export type { ClerkRequest }; diff --git a/packages/nextjs/src/server/getAuth.ts b/packages/nextjs/src/server/getAuth.ts index 8d088917c0..99996386a1 100644 --- a/packages/nextjs/src/server/getAuth.ts +++ b/packages/nextjs/src/server/getAuth.ts @@ -118,7 +118,9 @@ export const buildClerkProps: BuildClerkProps = (req, initState = {}) => { }; const parseJwt = (req: RequestLike) => { + // const handshakeToken = getHeader(req, 'Clerk-Handshake-Token'); const cookieToken = getCookie(req, constants.Cookies.Session); const headerToken = getHeader(req, 'authorization')?.replace('Bearer ', ''); + // return decodeJwt(handshakeToken || cookieToken || headerToken || ''); return decodeJwt(cookieToken || headerToken || ''); }; diff --git a/packages/nextjs/src/server/handshake.ts b/packages/nextjs/src/server/handshake.ts new file mode 100644 index 0000000000..606882e3fd --- /dev/null +++ b/packages/nextjs/src/server/handshake.ts @@ -0,0 +1,189 @@ +import { verifyToken } from '@clerk/backend'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { joinPaths } from '@clerk/shared/url'; +import { serialize as serializeCookie } from 'cookie'; + +import type { ClerkRequest } from './clerk-request'; +import { createClerkRequest } from './clerk-request'; +import { API_URL, API_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from './constants'; +import type { WithAuthOptions } from './types'; + +export const AuthenticateRequestStatus = { + SignedOut: 'signed-out', + SignedIn: 'signed-in', + Handshake: 'handshake', +} as const; + +export type AuthenticateRequestStatus = (typeof AuthenticateRequestStatus)[keyof typeof AuthenticateRequestStatus]; + +export const authenticateRequestHandshake = async ( + req: Request, + opts: WithAuthOptions, +): Promise => { + const clerkReq = createClerkRequest(req); + const options = { + ...opts, + secretKey: opts.secretKey || SECRET_KEY, + publishableKey: opts.publishableKey || PUBLISHABLE_KEY, + apiUrl: API_URL, + }; + console.log('authenticateRequestHandshake', { options }); + + if (clerkReq.headers.get('Authorization')) { + return authenticateRequestWithAuthorizationHeader(clerkReq, options); + } + + const handshakeResult = getHandshakeResult(clerkReq); + if (handshakeResult) { + return parseHandshakeResult(handshakeResult, options); + } + + return authenticateRequestWithCookies(clerkReq, options); +}; + +/** + * Init a handshake with Clerk FAPI by returning a 307 redirect to the /handshake endpoint. + */ +export const startHandshake = async (request: Request, _opts: WithAuthOptions) => { + // TODO: + const options = { + ..._opts, + secretKey: _opts.secretKey || SECRET_KEY, + publishableKey: _opts.publishableKey || PUBLISHABLE_KEY, + apiUrl: API_URL, + }; + console.log('startHandshake', { options }); + const req = createClerkRequest(request); + const parsedKey = parsePublishableKey(options.publishableKey); + if (!parsedKey) { + throw new Error('Invalid publishable key'); + } + + const url = new URL(`https://${parsedKey.frontendApi}`); + url.pathname = joinPaths(url.pathname, API_VERSION, '/client/handshake'); + url.searchParams.set('redirect_url', req.clerkUrl.toString()); + + const dbJwt = req.clerkUrl.searchParams.get('__clerk_db_jwt') || req.cookies.get('__clerk_db_jwt'); + if (dbJwt) { + url.searchParams.set('__clerk_db_jwt', dbJwt); + } + + return Response.redirect(url, 307); +}; + +const getHandshakeResult = (req: ClerkRequest) => { + return req.cookies.get('__clerk_handshake') || req.clerkUrl.searchParams.get('__clerk_handshake'); +}; + +const parseHandshakeResult = async (handshakeResult: string, opts: WithAuthOptions) => { + const cookiesToSet = JSON.parse(atob(handshakeResult)) as string[]; + const headersToSet = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + let token = ''; + + console.log('cookies', cookiesToSet); + console.log('headers before', headersToSet); + + cookiesToSet.forEach((x: string) => { + headersToSet.append('Set-Cookie', x); + if (x.startsWith('__session=')) { + token = x.split(';')[0].substring(10); + } + }); + + console.log('headers', headersToSet); + + console.log('token', token); + + if (token === '') { + return { status: AuthenticateRequestStatus.SignedOut, headers: headersToSet }; + } + + try { + await verifyRequestState(token, opts); + return { status: AuthenticateRequestStatus.SignedIn, headers: headersToSet, handshakeToken: token }; + } catch (e: any) { + return { status: AuthenticateRequestStatus.SignedOut, reason: e.reason }; + } +}; + +const authenticateRequestWithAuthorizationHeader = async (req: ClerkRequest, opts: WithAuthOptions) => { + const authorizationHeader = req.headers.get('Authorization')!; + const token = authorizationHeader.replace('Bearer ', ''); + try { + await verifyRequestState(token, opts); + return { status: AuthenticateRequestStatus.SignedIn }; + } catch (e: any) { + return { status: AuthenticateRequestStatus.SignedOut, reason: e.reason }; + } +}; + +const authenticateRequestWithCookies = async (req: ClerkRequest, opts: WithAuthOptions) => { + // DevBrowser Refresh + const newDevBrowser = req.clerkUrl.searchParams.get('__clerk_db_jwt'); + if (newDevBrowser && newDevBrowser !== req.cookies.get('__clerk_db_jwt')) { + return { + status: AuthenticateRequestStatus.Handshake, + reason: 'new-dev-browser', + headers: new Headers({ + 'Set-Cookie': serializeCookie('__clerk_db_jwt', newDevBrowser, { path: '/' }), + }), + }; + } + + // Satellite handling + // TODO + + const clientUatStr = req.cookies.get('__client_uat'); + const clientUat = clientUatStr ? parseInt(clientUatStr) : null; + + // This can eagerly run handshake since client_uat is SameSite=Strict in dev + if (!clientUat) { + return req.cookies.get('__session') + ? { status: AuthenticateRequestStatus.Handshake, reason: 'cookie-token-unexpected' } + : { status: AuthenticateRequestStatus.SignedOut }; + } + + if (clientUat > 0) { + const token = req.cookies.get('__session'); + if (!token) { + return { status: AuthenticateRequestStatus.Handshake, reason: 'cookie-token-missing' }; + } + + try { + const jwt = await verifyRequestState(token, opts); + return jwt.iat < clientUat + ? { status: AuthenticateRequestStatus.Handshake, reason: 'cookie-token-stale' } + : { status: AuthenticateRequestStatus.SignedIn }; + } catch (e) { + return { status: AuthenticateRequestStatus.Handshake, reason: 'cookie-token-invalid' }; + } + } + + return { status: AuthenticateRequestStatus.SignedOut, reason: 'unexpected' }; +}; + +// TODO @nikos: this is a copy of the function from @clerk/backend +// rethink options +async function verifyRequestState(token: string, options: any) { + 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 }); +} + +type AuthenticateRequestResult = { + status: AuthenticateRequestStatus; + reason?: string; + headers?: Headers; + handshakeToken?: string; +}; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index de745f5e9b..86324ba038 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -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: Request, opts: WithAuthOptions) => { const requestURL = buildRequestUrl(req); const relativeOrAbsoluteProxyUrl = handleValueOrFn(opts?.proxyUrl, requestURL, PROXY_URL); let proxyUrl; diff --git a/packages/nextjs/src/utils/response.ts b/packages/nextjs/src/utils/response.ts index 760ea6939d..c43867b399 100644 --- a/packages/nextjs/src/utils/response.ts +++ b/packages/nextjs/src/utils/response.ts @@ -18,12 +18,16 @@ export const mergeResponses = (...responses: (NextResponse | Response | null | u for (const response of normalisedResponses) { response.headers.forEach((value: string, name: string) => { - finalResponse.headers.set(name, value); + if (name.toLocaleLowerCase() === 'set-cookie') { + finalResponse.headers.append(name, value); + } else { + finalResponse.headers.set(name, value); + } }); - response.cookies.getAll().forEach(cookie => { - finalResponse.cookies.set(cookie.name, cookie.value); - }); + // response.cookies.getAll().forEach(cookie => { + // finalResponse.cookies.set(cookie.name, cookie.value); + // }); } return finalResponse; diff --git a/packages/nextjs/tsconfig.declarations.json b/packages/nextjs/tsconfig.declarations.json index c42a5efd18..cd75cd637d 100644 --- a/packages/nextjs/tsconfig.declarations.json +++ b/packages/nextjs/tsconfig.declarations.json @@ -8,5 +8,6 @@ "declarationMap": true, "sourceMap": false, "declarationDir": "./dist/types" - } + }, + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } 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); +}