From 513a3b23c28efd188ed3aa9a715bb62f1bcfa6ff Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 11 Jul 2025 12:02:45 -0500 Subject: [PATCH] feat: wip --- packages/backend/src/index.ts | 11 +- packages/backend/src/internal.ts | 5 + .../tokens/__tests__/outageResilience.test.ts | 235 +++++++++++++++ packages/backend/src/tokens/handshake.ts | 34 ++- .../backend/src/tokens/outageResilience.ts | 278 ++++++++++++++++++ packages/backend/src/tokens/request.ts | 5 +- packages/backend/src/tokens/types.ts | 5 + 7 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/tokens/__tests__/outageResilience.test.ts create mode 100644 packages/backend/src/tokens/outageResilience.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fe902146b11..80a8943ad0f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -15,7 +15,7 @@ export type ClerkOptions = CreateBackendApiOptions & Partial< Pick< CreateAuthenticateRequestOptions['options'], - 'audience' | 'jwtKey' | 'proxyUrl' | 'secretKey' | 'publishableKey' | 'domain' | 'isSatellite' + 'audience' | 'jwtKey' | 'proxyUrl' | 'secretKey' | 'publishableKey' | 'domain' | 'isSatellite' | 'outageResilienceConfig' > > & { sdkMetadata?: SDKMetadata; telemetry?: Pick }; @@ -164,3 +164,12 @@ export type { */ export type { AuthObject, InvalidTokenAuthObject } from './tokens/authObjects'; export type { SessionAuthObject, MachineAuthObject } from './tokens/types'; + +/** + * Outage resilience + */ +export type { + OutageResilienceConfig, + SdkCapabilityResult, + SdkCapabilityIndicators +} from './tokens/outageResilience'; diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 614bd13443e..9db73c98dfc 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -14,6 +14,11 @@ export type { InferAuthObjectFromTokenArray, GetAuthFn, } from './tokens/types'; +export type { + OutageResilienceConfig, + SdkCapabilityResult, + SdkCapabilityIndicators, +} from './tokens/outageResilience'; export { TokenType } from './tokens/tokenTypes'; export type { SessionTokenType, MachineTokenType } from './tokens/tokenTypes'; diff --git a/packages/backend/src/tokens/__tests__/outageResilience.test.ts b/packages/backend/src/tokens/__tests__/outageResilience.test.ts new file mode 100644 index 00000000000..feffe9e48b3 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/outageResilience.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AuthenticateContext } from '../authenticateContext'; +import type { SdkCapabilityResult } from '../outageResilience'; +import { OutageResilienceService } from '../outageResilience'; + +describe('OutageResilienceService', () => { + let service: OutageResilienceService; + let mockContext: Partial; + + beforeEach(() => { + service = new OutageResilienceService(); + mockContext = { + request: { + headers: new Map(), + } as any, + getQueryParam: vi.fn(), + }; + }); + + describe('hasBuiltInOutageResiliency', () => { + it('should detect resilience via explicit header', () => { + const headers = new Map([ + ['X-Clerk-Outage-Resilient', 'true'], + ['X-Clerk-SDK', 'clerk-js'], + ['X-Clerk-SDK-Version', '5.72.0'], + ['X-Clerk-Handshake-Retry', '2'], + ]); + mockContext.request = { headers } as any; + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: true, + sdkIdentifier: 'clerk-js', + sdkVersion: '5.72.0', + retryAttempt: 2, + reason: 'Explicit outage resilience header detected', + }); + }); + + it('should detect resilience via User-Agent pattern', () => { + const headers = new Map([['User-Agent', 'Mozilla/5.0 (compatible; @clerk/clerk-js@5.72.1)']]); + mockContext.request = { headers } as any; + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: true, + sdkIdentifier: 'clerk-js', + sdkVersion: '5.72.1', + reason: 'SDK clerk-js@5.72.1 supports outage resilience', + }); + }); + + it('should reject older SDK versions', () => { + const headers = new Map([['User-Agent', 'Mozilla/5.0 (compatible; @clerk/clerk-js@5.71.0)']]); + mockContext.request = { headers } as any; + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: false, + sdkIdentifier: 'clerk-js', + sdkVersion: '5.71.0', + reason: 'SDK clerk-js@5.71.0 does not support outage resilience', + }); + }); + + it('should detect resilience via query parameter fallback', () => { + mockContext.getQueryParam = vi.fn().mockReturnValue('true'); + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: true, + reason: 'Outage resilience query parameter detected', + }); + expect(mockContext.getQueryParam).toHaveBeenCalledWith('outage_resilient'); + }); + + it('should return false when no indicators found', () => { + mockContext.request = { headers: new Map() } as any; + mockContext.getQueryParam = vi.fn().mockReturnValue(undefined); + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: false, + reason: 'No resilience indicators found', + }); + }); + + it('should return false when globally disabled', () => { + service = new OutageResilienceService({ enabled: false }); + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result).toEqual({ + hasBuiltInOutageResiliency: false, + reason: 'Outage resilience disabled globally', + }); + }); + }); + + describe('shouldServeErrorPage', () => { + it('should serve error page for non-resilient clients', () => { + mockContext.request = { headers: new Map() } as any; + mockContext.getQueryParam = vi.fn().mockReturnValue(undefined); + + const result = service.shouldServeErrorPage(mockContext as AuthenticateContext, '/v1/client/handshake'); + + expect(result).toEqual({ + shouldServeErrorPage: true, + reason: 'SDK does not support outage resilience: No resilience indicators found', + }); + }); + + it('should skip error page for resilient clients', () => { + const headers = new Map([['X-Clerk-Outage-Resilient', 'true']]); + mockContext.request = { headers } as any; + + const result = service.shouldServeErrorPage(mockContext as AuthenticateContext, '/v1/client/handshake'); + + expect(result).toEqual({ + shouldServeErrorPage: false, + reason: 'SDK supports outage resilience: Explicit outage resilience header detected', + }); + }); + + it('should serve error page for unsupported endpoints', () => { + const headers = new Map([['X-Clerk-Outage-Resilient', 'true']]); + mockContext.request = { headers } as any; + + const result = service.shouldServeErrorPage(mockContext as AuthenticateContext, '/v1/users'); + + expect(result).toEqual({ + shouldServeErrorPage: true, + reason: 'Endpoint does not support outage resilience', + }); + }); + }); + + describe('createResilienceHeaders', () => { + it('should create appropriate headers for resilient clients', () => { + const capability: SdkCapabilityResult = { + hasBuiltInOutageResiliency: true, + sdkIdentifier: 'clerk-js', + retryAttempt: 3, + reason: 'Test', + }; + + const headers = service.createResilienceHeaders(capability); + + expect(headers.get('X-Clerk-Outage-Resiliency')).toBe('active'); + expect(headers.get('X-Clerk-SDK-Detected')).toBe('clerk-js'); + expect(headers.get('X-Clerk-Retry-Attempt')).toBe('3'); + expect(headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate'); + expect(headers.get('Pragma')).toBe('no-cache'); + expect(headers.get('Expires')).toBe('0'); + }); + + it('should create cache headers for non-resilient clients', () => { + const capability: SdkCapabilityResult = { + hasBuiltInOutageResiliency: false, + reason: 'Test', + }; + + const headers = service.createResilienceHeaders(capability); + + expect(headers.get('X-Clerk-Outage-Resiliency')).toBeNull(); + expect(headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate'); + expect(headers.get('Pragma')).toBe('no-cache'); + expect(headers.get('Expires')).toBe('0'); + }); + }); + + describe('SDK version detection', () => { + const testCases = [ + { + userAgent: '@clerk/nextjs@5.8.0', + expected: { hasResilience: true, name: 'nextjs', version: '5.8.0' }, + }, + { + userAgent: '@clerk/nextjs@5.7.9', + expected: { hasResilience: false, name: 'nextjs', version: '5.7.9' }, + }, + { + userAgent: '@clerk/clerk-react@5.12.1', + expected: { hasResilience: true, name: 'clerk-react', version: '5.12.1' }, + }, + { + userAgent: '@clerk/clerk-react@5.11.9', + expected: { hasResilience: false, name: 'clerk-react', version: '5.11.9' }, + }, + ]; + + testCases.forEach(({ userAgent, expected }) => { + it(`should correctly detect ${expected.name}@${expected.version} resilience: ${expected.hasResilience}`, () => { + const headers = new Map([['User-Agent', userAgent]]); + mockContext.request = { headers } as any; + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result.hasBuiltInOutageResiliency).toBe(expected.hasResilience); + expect(result.sdkIdentifier).toBe(expected.name); + expect(result.sdkVersion).toBe(expected.version); + }); + }); + }); + + describe('configuration options', () => { + it('should respect custom endpoint configuration', () => { + service = new OutageResilienceService({ + supportedEndpoints: ['/custom/endpoint'], + }); + + expect(service.isEndpointSupported('/v1/client/handshake')).toBe(false); + expect(service.isEndpointSupported('/custom/endpoint')).toBe(true); + }); + + it('should respect retry attempt limits', () => { + service = new OutageResilienceService({ + maxRetryAttempts: 3, + }); + + const headers = new Map([['X-Clerk-Handshake-Retry', '5']]); + mockContext.request = { headers } as any; + + const result = service.hasBuiltInOutageResiliency(mockContext as AuthenticateContext); + + expect(result.retryAttempt).toBe(3); // Capped at maxRetryAttempts + }); + }); +}); diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 18ba6dc6080..7ba41fe762f 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -9,6 +9,8 @@ import { AuthErrorReason, signedIn, signedOut } from './authStatus'; import { getCookieName, getCookieValue } from './cookie'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; import type { OrganizationMatcher } from './organizationMatcher'; +import type { OutageResilienceConfig, SdkCapabilityResult } from './outageResilience'; +import { OutageResilienceService } from './outageResilience'; import { TokenType } from './tokenTypes'; import type { OrganizationSyncOptions, OrganizationSyncTarget } from './types'; import type { VerifyTokenOptions } from './verify'; @@ -87,15 +89,17 @@ export class HandshakeService { private readonly authenticateContext: AuthenticateContext; private readonly organizationMatcher: OrganizationMatcher; private readonly options: { organizationSyncOptions?: OrganizationSyncOptions }; + private readonly outageResilienceService: OutageResilienceService; constructor( authenticateContext: AuthenticateContext, - options: { organizationSyncOptions?: OrganizationSyncOptions }, + options: { organizationSyncOptions?: OrganizationSyncOptions; outageResilienceConfig?: Partial }, organizationMatcher: OrganizationMatcher, ) { this.authenticateContext = authenticateContext; this.options = options; this.organizationMatcher = organizationMatcher; + this.outageResilienceService = new OutageResilienceService(options.outageResilienceConfig); } /** @@ -122,6 +126,20 @@ export class HandshakeService { return false; } + /** + * Determines if the current request has built-in outage resilience support + */ + hasBuiltInOutageResiliency(): SdkCapabilityResult { + return this.outageResilienceService.hasBuiltInOutageResiliency(this.authenticateContext); + } + + /** + * Determines if we should serve an error page or redirect directly for outages + */ + shouldServeErrorPage(endpoint: string): { shouldServeErrorPage: boolean; reason: string } { + return this.outageResilienceService.shouldServeErrorPage(this.authenticateContext, endpoint); + } + /** * Builds the redirect headers for a handshake request * @param reason - The reason for the handshake (e.g. 'session-token-expired') @@ -163,7 +181,19 @@ export class HandshakeService { }); } - return new Headers({ [constants.Headers.Location]: url.href }); + // Create base headers with Location + const headers = new Headers({ [constants.Headers.Location]: url.href }); + + // Add outage resilience headers if applicable + const capability = this.hasBuiltInOutageResiliency(); + const resilienceHeaders = this.outageResilienceService.createResilienceHeaders(capability); + + // Merge resilience headers into the response headers + resilienceHeaders.forEach((value, key) => { + headers.set(key, value); + }); + + return headers; } /** diff --git a/packages/backend/src/tokens/outageResilience.ts b/packages/backend/src/tokens/outageResilience.ts new file mode 100644 index 00000000000..25f35be397c --- /dev/null +++ b/packages/backend/src/tokens/outageResilience.ts @@ -0,0 +1,278 @@ +import type { AuthenticateContext } from './authenticateContext'; + +/** + * Configuration for outage resilience behavior + */ +export interface OutageResilienceConfig { + /** Whether outage resilience is enabled globally */ + enabled: boolean; + /** Endpoints that support outage resilience */ + supportedEndpoints: string[]; + /** Maximum retry attempts to track */ + maxRetryAttempts: number; +} + +/** + * Default configuration for outage resilience + */ +export const DEFAULT_OUTAGE_RESILIENCE_CONFIG: OutageResilienceConfig = { + enabled: true, + supportedEndpoints: ['/v1/client/sync', '/v1/client/handshake'], + maxRetryAttempts: 5, +}; + +/** + * SDK capability indicators that can be used to detect outage resilience support + */ +export interface SdkCapabilityIndicators { + /** Header indicating SDK supports outage resilience */ + outageResilientHeader?: string; + /** SDK identifier header */ + sdkHeader?: string; + /** SDK version header */ + sdkVersionHeader?: string; + /** User-Agent string patterns */ + userAgent?: string; + /** Query parameter indicating resilience */ + outageResilientParam?: string; + /** Retry attempt header */ + retryAttemptHeader?: string; +} + +/** + * Result of SDK capability detection + */ +export interface SdkCapabilityResult { + /** Whether the SDK supports built-in outage resilience */ + hasBuiltInOutageResiliency: boolean; + /** SDK identifier if detected */ + sdkIdentifier?: string; + /** SDK version if detected */ + sdkVersion?: string; + /** Current retry attempt number */ + retryAttempt?: number; + /** Reason for the determination */ + reason: string; +} + +/** + * Service for managing outage resilience behavior + */ +export class OutageResilienceService { + private readonly config: OutageResilienceConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_OUTAGE_RESILIENCE_CONFIG, ...config }; + } + + /** + * Determines if a request has built-in outage resilience based on headers and context + */ + public hasBuiltInOutageResiliency(authenticateContext: AuthenticateContext): SdkCapabilityResult { + if (!this.config.enabled) { + return { + hasBuiltInOutageResiliency: false, + reason: 'Outage resilience disabled globally', + }; + } + + const indicators = this.extractSdkCapabilityIndicators(authenticateContext); + + // Check explicit outage resilience header + if (indicators.outageResilientHeader?.toLowerCase() === 'true') { + return { + hasBuiltInOutageResiliency: true, + sdkIdentifier: indicators.sdkHeader, + sdkVersion: indicators.sdkVersionHeader, + retryAttempt: this.parseRetryAttempt(indicators.retryAttemptHeader), + reason: 'Explicit outage resilience header detected', + }; + } + + // Check for known SDK patterns in User-Agent + const sdkFromUserAgent = this.detectSdkFromUserAgent(indicators.userAgent); + if (sdkFromUserAgent.hasBuiltInOutageResiliency) { + return { + ...sdkFromUserAgent, + retryAttempt: this.parseRetryAttempt(indicators.retryAttemptHeader), + }; + } + + // Check query parameter (fallback method) + if (indicators.outageResilientParam?.toLowerCase() === 'true') { + return { + hasBuiltInOutageResiliency: true, + reason: 'Outage resilience query parameter detected', + retryAttempt: this.parseRetryAttempt(indicators.retryAttemptHeader), + }; + } + + return { + hasBuiltInOutageResiliency: false, + reason: 'No resilience indicators found', + }; + } + + /** + * Checks if the current endpoint supports outage resilience + */ + public isEndpointSupported(path: string): boolean { + return this.config.supportedEndpoints.some(endpoint => path.includes(endpoint)); + } + + /** + * Determines if we should serve an error page or redirect directly + */ + public shouldServeErrorPage( + authenticateContext: AuthenticateContext, + endpoint: string, + ): { shouldServeErrorPage: boolean; reason: string } { + if (!this.isEndpointSupported(endpoint)) { + return { + shouldServeErrorPage: true, + reason: 'Endpoint does not support outage resilience', + }; + } + + const capability = this.hasBuiltInOutageResiliency(authenticateContext); + + if (capability.hasBuiltInOutageResiliency) { + return { + shouldServeErrorPage: false, + reason: `SDK supports outage resilience: ${capability.reason}`, + }; + } + + return { + shouldServeErrorPage: true, + reason: `SDK does not support outage resilience: ${capability.reason}`, + }; + } + + /** + * Creates headers for outage resilience responses + */ + public createResilienceHeaders(capability: SdkCapabilityResult): Headers { + const headers = new Headers(); + + if (capability.hasBuiltInOutageResiliency) { + headers.set('X-Clerk-Outage-Resiliency', 'active'); + + if (capability.sdkIdentifier) { + headers.set('X-Clerk-SDK-Detected', capability.sdkIdentifier); + } + + if (capability.retryAttempt) { + headers.set('X-Clerk-Retry-Attempt', capability.retryAttempt.toString()); + } + } + + // Always add cache control for error scenarios to prevent caching of temporary failures + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + return headers; + } + + /** + * Extracts SDK capability indicators from the request context + */ + private extractSdkCapabilityIndicators(authenticateContext: AuthenticateContext): SdkCapabilityIndicators { + // Access headers from the request context + const headers = authenticateContext.request?.headers; + + return { + outageResilientHeader: headers?.get?.('X-Clerk-Outage-Resilient') || headers?.get?.('x-clerk-outage-resilient'), + sdkHeader: headers?.get?.('X-Clerk-SDK') || headers?.get?.('x-clerk-sdk'), + sdkVersionHeader: headers?.get?.('X-Clerk-SDK-Version') || headers?.get?.('x-clerk-sdk-version'), + userAgent: headers?.get?.('User-Agent') || headers?.get?.('user-agent'), + retryAttemptHeader: headers?.get?.('X-Clerk-Handshake-Retry') || headers?.get?.('x-clerk-handshake-retry'), + outageResilientParam: authenticateContext.getQueryParam?.('outage_resilient'), + }; + } + + /** + * Detects SDK type and capabilities from User-Agent string + */ + private detectSdkFromUserAgent(userAgent?: string): Omit { + if (!userAgent) { + return { + hasBuiltInOutageResiliency: false, + reason: 'No User-Agent header found', + }; + } + + // Pattern matching for known SDKs with outage resilience support + const sdkPatterns = [ + { + pattern: /@clerk\/clerk-js@(\d+\.\d+\.\d+)/, + name: 'clerk-js', + hasOutageResiliency: (version: string) => this.isVersionAtLeast(version, '5.72.0'), + }, + { + pattern: /@clerk\/nextjs@(\d+\.\d+\.\d+)/, + name: 'nextjs', + hasOutageResiliency: (version: string) => this.isVersionAtLeast(version, '5.8.0'), + }, + { + pattern: /@clerk\/clerk-react@(\d+\.\d+\.\d+)/, + name: 'clerk-react', + hasOutageResiliency: (version: string) => this.isVersionAtLeast(version, '5.12.0'), + }, + // Add more SDK patterns as they implement outage resilience + ]; + + for (const { pattern, name, hasOutageResiliency } of sdkPatterns) { + const match = userAgent.match(pattern); + if (match) { + const version = match[1]; + const hasResilience = hasOutageResiliency(version); + + return { + hasBuiltInOutageResiliency: hasResilience, + sdkIdentifier: name, + sdkVersion: version, + reason: hasResilience + ? `SDK ${name}@${version} supports outage resilience` + : `SDK ${name}@${version} does not support outage resilience`, + }; + } + } + + return { + hasBuiltInOutageResiliency: false, + reason: 'No known resilient SDK detected in User-Agent', + }; + } + + /** + * Parses retry attempt number from header + */ + private parseRetryAttempt(retryHeader?: string): number | undefined { + if (!retryHeader) return undefined; + + const attempt = parseInt(retryHeader, 10); + return isNaN(attempt) ? undefined : Math.min(attempt, this.config.maxRetryAttempts); + } + + /** + * Compares version strings to determine if a version meets minimum requirements + */ + private isVersionAtLeast(version: string, minVersion: string): boolean { + const parseVersion = (v: string) => v.split('.').map(n => parseInt(n, 10)); + + const versionParts = parseVersion(version); + const minVersionParts = parseVersion(minVersion); + + for (let i = 0; i < Math.max(versionParts.length, minVersionParts.length); i++) { + const vPart = versionParts[i] || 0; + const minPart = minVersionParts[i] || 0; + + if (vPart > minPart) return true; + if (vPart < minPart) return false; + } + + return true; // Equal versions + } +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a8064934a8e..76b2e94a672 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -155,7 +155,10 @@ export const authenticateRequest: AuthenticateRequest = (async ( const organizationMatcher = new OrganizationMatcher(options.organizationSyncOptions); const handshakeService = new HandshakeService( authenticateContext, - { organizationSyncOptions: options.organizationSyncOptions }, + { + organizationSyncOptions: options.organizationSyncOptions, + outageResilienceConfig: options.outageResilienceConfig, + }, organizationMatcher, ); diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 2b95dfb6c23..e0285c3f5b9 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -10,6 +10,7 @@ import type { SignedOutAuthObject, UnauthenticatedMachineObject, } from './authObjects'; +import type { OutageResilienceConfig } from './outageResilience'; import type { SessionTokenType, TokenType } from './tokenTypes'; import type { VerifyTokenOptions } from './verify'; @@ -58,6 +59,10 @@ export type AuthenticateRequestOptions = { * If the activation can't be performed, either because an organization doesn't exist or the user lacks access, the active organization in the session won't be changed. Ultimately, it's the responsibility of the page to verify that the resources are appropriate to render given the URL and handle mismatches appropriately (e.g., by returning a 404). */ organizationSyncOptions?: OrganizationSyncOptions; + /** + * Configuration for outage resilience behavior + */ + outageResilienceConfig?: Partial; /** * @internal */