Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jwt-expired-handler.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 85 additions & 1 deletion src/MOSProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -42,6 +44,31 @@ export interface MOSProxyHtmlPipelineErrorContext {
*/
export type MOSProxyHtmlPipelineErrorHandler = (ctx: MOSProxyHtmlPipelineErrorContext) => Response | Promise<Response>

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<Response | undefined>

export interface MOSProxyOptions {
/** A static config, or a per-request {@link ConfigFactory}. */
config: MOSConfigInput | ConfigFactory
Expand All @@ -57,6 +84,7 @@ export interface MOSProxyOptions {
maxCachedConfigs?: number
logger?: MOSProxyLogger
onHtmlPipelineError?: MOSProxyHtmlPipelineErrorHandler
onJwtExpired?: MOSProxyJwtExpiredHandler | null
customEndpointsEnabled: boolean
linkRewritingEnabled: boolean
surfaceDecisionsEnabled: boolean
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -236,6 +269,57 @@ export class MOSProxy {
}
}

const handleExpiredJwt = async (
request: Request,
config: MOSConfig,
handler: MOSProxyJwtExpiredHandler | null,
logger: MOSProxyLogger,
): Promise<Response | null> => {
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()
Expand Down
16 changes: 15 additions & 1 deletion src/MOSProxyBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export {
type MOSProxyHtmlPipelineErrorContext,
type MOSProxyHtmlPipelineErrorHandler,
type MOSProxyHtmlPipelineStage,
type MOSProxyJwtExpiredContext,
type MOSProxyJwtExpiredHandler,
type MOSProxyOptions,
} from './MOSProxy'
export { MOSProxyBuilder } from './MOSProxyBuilder'
Expand Down
50 changes: 50 additions & 0 deletions src/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export interface ExpiredJwt {
claims: Record<string, unknown>
/** `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<string, unknown> | 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<string, unknown>
} 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)
}
2 changes: 2 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading