diff --git a/.changeset/jwt-expired-handler.md b/.changeset/jwt-expired-handler.md new file mode 100644 index 00000000..85866357 --- /dev/null +++ b/.changeset/jwt-expired-handler.md @@ -0,0 +1,5 @@ +--- +"@monetizationos/proxy": minor +--- + +Add `MOSProxyBuilder.withJwtExpiredHandler` so a proxy can react when the authenticated-user JWT cookie is past `exp`. Return a `Response` to refresh or redirect, or return nothing to keep proxying. diff --git a/README.md b/README.md index e4e77273..c40c9c5e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,40 @@ Skip HTML transformation entirely when the proxy only needs to handle API traffi const proxy = new MOSProxyBuilder().withConfig(config).withoutHtmlTransformation().build(); ``` +## Expired JWT + +`.withJwtExpiredHandler(...)` runs when the incoming request's authenticated-user JWT cookie (`authenticatedUserJwtCookieName`) has an `exp` claim in the past. Expiry is read from the unverified payload, before the origin fetch, so a worker can replace the reply — for example with a page reload — or return nothing and let the proxy continue. MonetizationOS still authenticates the token when the request continues. + +The callback runs for every request that presents the expired cookie, including assets and `/mos-endpoints/`. Limit a reload to document navigations inside the handler. + +```ts +const proxy = new MOSProxyBuilder() + .withConfig(config) + .withHtmlRewriter(myHtmlRewriterAdapter) + .withJwtExpiredHandler(({ request }) => { + if (request.headers.get("Sec-Fetch-Dest") !== "document") { + return; + } + + const url = new URL(request.url); + if (url.searchParams.has("reloaded")) { + return; + } + url.searchParams.set("reloaded", "1"); + + return new Response(null, { + status: 200, + headers: { + Refresh: `0; url=${url}`, + "Cache-Control": "no-store", + }, + }); + }) + .build(); +``` + +The first document request reloads once, to the same page with `?reloaded=1`. That follow-up still carries the expired JWT, but the handler returns nothing, so the proxy continues and forwards the token to the MonetizationOS API. A reload of the exact same URL, with no flag, would send the expired cookie again and the handler would reload forever. + ## Error handling and logging The HTML pipeline fails open by default: on error, the proxy logs a structured event and returns the last safe response so the origin page still gets served. Register an error handler to shape that response yourself — render a custom error page, return a 503, or rethrow so the host runtime's error middleware takes over. diff --git a/src/MOSProxy.ts b/src/MOSProxy.ts index 4421b47e..29486bee 100644 --- a/src/MOSProxy.ts +++ b/src/MOSProxy.ts @@ -3,10 +3,12 @@ import type { ClientMetadataProvider } from './adapters/ClientMetadataProvider' import type { ConfigFactory, UnresolvedConfigHandler } from './adapters/ConfigFactory' import type { Fetcher } from './adapters/Fetcher' import type { HtmlRewriterAdapter } from './adapters/HtmlRewriterAdapter' -import type { IdentityProvider } from './adapters/IdentityProvider' +import { getExistingCookies, type IdentityProvider } from './adapters/IdentityProvider' import type { ResourceProvider } from './adapters/ResourceProvider' +import type { MOSConfig } from './config' import { ConfigResolution } from './configResolution' import type { PipelineContext } from './context' +import { type ExpiredJwt, readExpiredJwt } from './jwt' import { consoleLogger, type MOSProxyLogger } from './logger' import customEndpointRequest from './stages/customEndpoint' import isRedirectResponse from './stages/isRedirectResponse' @@ -42,6 +44,31 @@ export interface MOSProxyHtmlPipelineErrorContext { */ export type MOSProxyHtmlPipelineErrorHandler = (ctx: MOSProxyHtmlPipelineErrorContext) => Response | Promise +export interface MOSProxyJwtExpiredContext { + request: Request + config: MOSConfig + /** Raw JWT from `authenticatedUserJwtCookieName`. Unverified. */ + jwt: string + claims: ExpiredJwt['claims'] + /** `exp` claim as unix seconds. */ + expiredAt: number +} + +/** + * Called when the incoming request carries an authenticated-user JWT whose `exp` is in the past. + * Return a `Response` to replace the proxy reply (for example a page reload). Return nothing to + * keep proxying. Runs before origin fetch and custom + * endpoint routing, on every request that presents the expired cookie — filter on `Sec-Fetch-Dest` + * or `Accept` if only document navigations should be intercepted. + * + * Expiry is read from the unverified payload. MonetizationOS still authenticates the token when + * the handler lets the request continue. + * + * If the callback throws or returns a non-Response value, the proxy logs a warning and continues + * the pipeline. + */ +export type MOSProxyJwtExpiredHandler = (ctx: MOSProxyJwtExpiredContext) => Response | undefined | Promise + export interface MOSProxyOptions { /** A static config, or a per-request {@link ConfigFactory}. */ config: MOSConfigInput | ConfigFactory @@ -57,6 +84,7 @@ export interface MOSProxyOptions { maxCachedConfigs?: number logger?: MOSProxyLogger onHtmlPipelineError?: MOSProxyHtmlPipelineErrorHandler + onJwtExpired?: MOSProxyJwtExpiredHandler | null customEndpointsEnabled: boolean linkRewritingEnabled: boolean surfaceDecisionsEnabled: boolean @@ -103,6 +131,11 @@ export class MOSProxy { } const ctx: PipelineContext = { config: resolved, logger } + const expiredJwtResponse = await handleExpiredJwt(request, resolved, this.opts.onJwtExpired ?? null, logger) + if (expiredJwtResponse) { + return expiredJwtResponse + } + // Stage 1a: custom endpoint routing if (this.opts.customEndpointsEnabled) { if (!apiFetcher) { @@ -236,6 +269,57 @@ export class MOSProxy { } } +const handleExpiredJwt = async ( + request: Request, + config: MOSConfig, + handler: MOSProxyJwtExpiredHandler | null, + logger: MOSProxyLogger, +): Promise => { + if (!handler) { + return null + } + + const userJwt = getExistingCookies(request, undefined, config).userJwt + if (!userJwt) { + return null + } + + const expired = readExpiredJwt(userJwt) + if (!expired) { + return null + } + + try { + const handlerResponse = await handler({ + request, + config, + jwt: userJwt, + claims: expired.claims, + expiredAt: expired.expiredAt, + }) + if (handlerResponse == null) { + return null + } + if (!(handlerResponse instanceof Response)) { + logger.log({ + level: 'warn', + code: 'jwt-expired-handler-invalid', + message: 'onJwtExpired did not return a Response; continuing the proxy pipeline.', + }) + return null + } + return handlerResponse + } catch (error) { + logger.log({ + level: 'warn', + code: 'jwt-expired-handler-threw', + message: 'onJwtExpired threw; continuing the proxy pipeline.', + error, + }) + return null + } +} + const cloneResponseForFallback = (response: Response, logger: MOSProxyLogger, stage: MOSProxyHtmlPipelineStage): Response => { try { return response.clone() diff --git a/src/MOSProxyBuilder.ts b/src/MOSProxyBuilder.ts index 91fd981b..6f4cdb2b 100644 --- a/src/MOSProxyBuilder.ts +++ b/src/MOSProxyBuilder.ts @@ -6,7 +6,7 @@ import type { HtmlRewriterAdapter } from './adapters/HtmlRewriterAdapter' import type { IdentityProvider } from './adapters/IdentityProvider' import type { ResourceProvider } from './adapters/ResourceProvider' import type { MOSProxyLogger } from './logger' -import { MOSProxy, type MOSProxyHtmlPipelineErrorHandler, type MOSProxyOptions } from './MOSProxy' +import { MOSProxy, type MOSProxyHtmlPipelineErrorHandler, type MOSProxyJwtExpiredHandler, type MOSProxyOptions } from './MOSProxy' import type { MOSConfigInput, MosAuthenticatedApiRoute } from './types' /** @@ -56,6 +56,7 @@ export class MOSProxyBuilder { private _configCacheSize: number | null = null private _logger: MOSProxyLogger | null = null private _onHtmlPipelineError: MOSProxyHtmlPipelineErrorHandler | null = null + private _onJwtExpired: MOSProxyJwtExpiredHandler | null = null private _customEndpoints = true private _linkRewriting = true private _surfaceDecisions = true @@ -163,6 +164,18 @@ export class MOSProxyBuilder { return this } + /** + * Register a callback for requests whose authenticated-user JWT cookie is past `exp`. + * Return a `Response` to take over (for example a page reload). + * Return nothing to keep proxying. The callback runs before origin fetch, on every request that + * presents the expired cookie. If it throws or returns a non-Response value, the proxy logs a + * warning and continues. + */ + withJwtExpiredHandler(handler: MOSProxyJwtExpiredHandler): this { + this._onJwtExpired = handler + return this + } + withoutCustomEndpoints(): this { this._customEndpoints = false return this @@ -224,6 +237,7 @@ export class MOSProxyBuilder { maxCachedConfigs: this._configCacheSize ?? undefined, logger: this._logger ?? undefined, onHtmlPipelineError: this._onHtmlPipelineError ?? undefined, + onJwtExpired: this._onJwtExpired ?? undefined, customEndpointsEnabled: this._customEndpoints, linkRewritingEnabled: this._linkRewriting, surfaceDecisionsEnabled: this._surfaceDecisions, diff --git a/src/index.ts b/src/index.ts index 3a254b14..36c37e77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ export { type MOSProxyHtmlPipelineErrorContext, type MOSProxyHtmlPipelineErrorHandler, type MOSProxyHtmlPipelineStage, + type MOSProxyJwtExpiredContext, + type MOSProxyJwtExpiredHandler, type MOSProxyOptions, } from './MOSProxy' export { MOSProxyBuilder } from './MOSProxyBuilder' diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 00000000..7c682114 --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,50 @@ +export interface ExpiredJwt { + claims: Record + /** `exp` claim as unix seconds. */ + expiredAt: number +} + +/** + * Reads an unverified JWT payload and returns it when `exp` is in the past. + * Returns null when the token is missing, not a JWT, has no numeric `exp`, or is still valid. + * Signature is not checked — callers use this only to notice expiry, not to authenticate. + */ +export function readExpiredJwt(token: string, nowSeconds = Date.now() / 1000): ExpiredJwt | null { + const claims = decodeJwtClaims(token) + if (!claims) { + return null + } + + const expiredAt = claims.exp + if (typeof expiredAt !== 'number' || !Number.isFinite(expiredAt) || nowSeconds < expiredAt) { + return null + } + + return { claims, expiredAt } +} + +const decodeJwtClaims = (token: string): Record | null => { + const payload = token.split('.')[1] + if (!payload) { + return null + } + + try { + const json = decodeBase64Url(payload) + const claims: unknown = JSON.parse(json) + if (typeof claims !== 'object' || claims === null || Array.isArray(claims)) { + return null + } + return claims as Record + } catch { + return null + } +} + +const decodeBase64Url = (value: string): string => { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=') + const binary = atob(padded) + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} diff --git a/src/logger.ts b/src/logger.ts index b2d3533c..fe90cb93 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -14,6 +14,8 @@ export type MOSProxyLogCode = | 'surface-decisions-api-failed' | 'identity-resolve-failed' | 'identity-persist-failed' + | 'jwt-expired-handler-invalid' + | 'jwt-expired-handler-threw' | 'link-rewriting-header-failed' | 'link-rewriting-body-failed' | 'marker-pass-invalid-state' diff --git a/test/MOSProxy.spec.ts b/test/MOSProxy.spec.ts index 8960186a..faee901c 100644 --- a/test/MOSProxy.spec.ts +++ b/test/MOSProxy.spec.ts @@ -817,6 +817,166 @@ describe('MOSProxy API-only mode (withoutHtmlTransformation)', () => { }) }) +const encodeJwtPart = (value: object) => { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +const sessionJwt = (payload: Record) => `${encodeJwtPart({ alg: 'none', typ: 'JWT' })}.${encodeJwtPart(payload)}.sig` + +describe('MOSProxy expired JWT handler', () => { + it('returns the handler response and skips the origin when the JWT cookie is expired', async () => { + const originFetcher = MockFetcher(() => htmlResponse('

origin

')) + const apiFetcher = MockFetcher(() => decisionsResponse()) + let sawExp: number | undefined + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(apiFetcher) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withJwtExpiredHandler(({ expiredAt, claims }) => { + sawExp = expiredAt + expect(claims.sub).toBe('user-1') + return new Response('refresh', { status: 401, headers: { Refresh: '0' } }) + }) + .build() + + const response = await proxy.handle( + new Request('https://proxy.example.com/article', { + headers: { Cookie: `__session=${sessionJwt({ sub: 'user-1', exp: 1 })}` }, + }), + ) + + expect(sawExp).toBe(1) + expect(response.status).toBe(401) + expect(response.headers.get('Refresh')).toBe('0') + expect(await response.text()).toBe('refresh') + expect(originFetcher.calls.length).toBe(0) + expect(apiFetcher.calls.length).toBe(0) + }) + + it('keeps proxying when the handler returns nothing', async () => { + const originFetcher = MockFetcher(() => new Response('asset', { status: 200, headers: { 'Content-Type': 'text/css' } })) + const apiFetcher = MockFetcher(() => decisionsResponse()) + let calls = 0 + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(apiFetcher) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withJwtExpiredHandler(() => { + calls += 1 + }) + .build() + + const response = await proxy.handle( + new Request('https://proxy.example.com/app.css', { + headers: { Cookie: `__session=${sessionJwt({ exp: 1 })}` }, + }), + ) + + expect(calls).toBe(1) + expect(await response.text()).toBe('asset') + expect(originFetcher.calls.length).toBe(1) + expect(apiFetcher.calls.length).toBe(0) + }) + + it('does not call the handler for a JWT that is still valid', async () => { + const originFetcher = MockFetcher(() => new Response('ok', { status: 200, headers: { 'Content-Type': 'text/plain' } })) + let calls = 0 + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(MockFetcher(() => decisionsResponse())) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withJwtExpiredHandler(() => { + calls += 1 + return new Response('nope', { status: 401 }) + }) + .build() + + const response = await proxy.handle( + new Request('https://proxy.example.com/article', { + headers: { Cookie: `__session=${sessionJwt({ exp: 9_999_999_999 })}` }, + }), + ) + + expect(calls).toBe(0) + expect(await response.text()).toBe('ok') + expect(originFetcher.calls.length).toBe(1) + }) + + it('proxies an expired JWT when no handler is registered', async () => { + const originFetcher = MockFetcher(() => new Response('ok', { status: 200, headers: { 'Content-Type': 'text/plain' } })) + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(MockFetcher(() => decisionsResponse())) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .build() + + const response = await proxy.handle( + new Request('https://proxy.example.com/article', { + headers: { Cookie: `__session=${sessionJwt({ exp: 1 })}` }, + }), + ) + + expect(await response.text()).toBe('ok') + expect(originFetcher.calls.length).toBe(1) + }) + + it('continues the pipeline when the handler throws or returns a non-Response', async () => { + const { events, logger } = createMemoryLogger() + const originFetcher = MockFetcher(() => new Response('ok', { status: 200, headers: { 'Content-Type': 'text/plain' } })) + + const throwing = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(MockFetcher(() => decisionsResponse())) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withLogger(logger) + .withJwtExpiredHandler(() => { + throw new Error('handler boom') + }) + .build() + + const thrown = await throwing.handle( + new Request('https://proxy.example.com/article', { + headers: { Cookie: `__session=${sessionJwt({ exp: 1 })}` }, + }), + ) + expect(await thrown.text()).toBe('ok') + expect(events).toContainEqual(expect.objectContaining({ level: 'warn', code: 'jwt-expired-handler-threw' })) + + events.length = 0 + const invalid = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(MockFetcher(() => decisionsResponse())) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withLogger(logger) + .withJwtExpiredHandler(() => 'refresh' as unknown as Response) + .build() + + const invalidResponse = await invalid.handle( + new Request('https://proxy.example.com/article', { + headers: { Cookie: `__session=${sessionJwt({ exp: 1 })}` }, + }), + ) + expect(await invalidResponse.text()).toBe('ok') + expect(events).toContainEqual(expect.objectContaining({ level: 'warn', code: 'jwt-expired-handler-invalid' })) + expect(originFetcher.calls.length).toBe(2) + }) +}) + describe('MOSProxyBuilder validation', () => { it('requires config', () => { expect(() => new MOSProxyBuilder().withOriginFetcher(MockFetcher(() => new Response(null))).build()).toThrow(/withConfig/) diff --git a/test/jwt.spec.ts b/test/jwt.spec.ts new file mode 100644 index 00000000..43a4304c --- /dev/null +++ b/test/jwt.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readExpiredJwt } from '../src/jwt' + +const encode = (value: object) => { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +const jwt = (payload: Record) => `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.sig` + +describe('readExpiredJwt', () => { + const nowSeconds = 1_700_000_000 + + it('returns claims when exp is in the past', () => { + const token = jwt({ sub: 'user-1', exp: nowSeconds - 1 }) + + expect(readExpiredJwt(token, nowSeconds)).toEqual({ + claims: { sub: 'user-1', exp: nowSeconds - 1 }, + expiredAt: nowSeconds - 1, + }) + }) + + it('treats exp equal to now as expired', () => { + const token = jwt({ exp: nowSeconds }) + + expect(readExpiredJwt(token, nowSeconds)?.expiredAt).toBe(nowSeconds) + }) + + it('returns null when the token is still valid', () => { + expect(readExpiredJwt(jwt({ exp: nowSeconds + 1 }), nowSeconds)).toBeNull() + }) + + it('returns null when exp is missing or not a number', () => { + expect(readExpiredJwt(jwt({ sub: 'user-1' }), nowSeconds)).toBeNull() + expect(readExpiredJwt(jwt({ exp: '1700000000' }), nowSeconds)).toBeNull() + }) + + it('returns null for tokens that are not JWTs', () => { + expect(readExpiredJwt('not-a-jwt', nowSeconds)).toBeNull() + expect(readExpiredJwt('a.%%%not-base64%%%.c', nowSeconds)).toBeNull() + expect(readExpiredJwt('', nowSeconds)).toBeNull() + }) + + it('decodes unicode claims', () => { + const token = jwt({ name: 'José', exp: 1 }) + + expect(readExpiredJwt(token, nowSeconds)?.claims.name).toBe('José') + }) +})