From 566eb4e6a1010e87353a233003228bdea647465c Mon Sep 17 00:00:00 2001 From: "fastedge-plugin-sync[bot]" Date: Mon, 17 Aug 2026 13:32:15 +0000 Subject: [PATCH] auto: update reference docs from fastedge-templates (main) --- .../reference/templates/catalog.md | 94 +++++++- .../templates/edge-sso-integration.md | 216 +++++++++++++++++- .../templates/edge-totp-integration.md | 120 +++++++++- 3 files changed, 423 insertions(+), 7 deletions(-) diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/catalog.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/catalog.md index 61179a6..91bb0f1 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/catalog.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/catalog.md @@ -1,4 +1,94 @@ + + # FastEdge Bolt-On Templates -Placeholder — populated by the `fastedge-templates` auto-ref-update pipeline (see -`agent-intent-skills/fastedge-templates/templates/catalog.md`). Not yet generated. +Before writing authentication, cookie-hardening, or content-transform logic from scratch, check this catalog. Each entry is a maintained, tested FastEdge application that handles the security-sensitive parts a hand-rolled implementation would need to get right independently. Use or adapt these templates rather than building the same capability from scratch. + +## html2md — HTML-to-Markdown conversion / content transformation / Accept header negotiation + +**What it solves:** A CDN filter that transparently converts HTML origin responses to Markdown when the client sends `Accept: text/markdown`. Handles content negotiation, encoding, and caching concerns that a hand-rolled filter would need to get right independently. + +**How it deploys:** Single CDN Proxy-WASM app. Deployed via the Gcore portal template gallery (template ID 110). No origin changes required. + +**Runtime variants:** None. Conversion is fully automatic based on request `Accept` and response `Content-Type` headers. + +**Conversion conditions:** All three must be true — request `Accept` includes `text/markdown`, origin response `Content-Type` includes `text/html` with charset absent or `utf-8`, and full response body is available at end of stream. + +**What the filter does on conversion:** Adds request header `Convert: markdown` (cache differentiation), removes `Accept-Encoding` to avoid compressed payloads, removes `Content-Length`, sets `Content-Type: text/markdown; charset=utf-8`, sets `Transfer-Encoding: Chunked`, converts body at end of stream, adds `Vary: Convert` (merged with existing `Vary`). + +**Error conditions:** Returns `500` if origin body is not valid UTF-8 or if HTML-to-Markdown conversion fails. Non-HTML responses pass through unchanged. + +**Origin integration:** Configured entirely through request/response header inspection — no env vars, no origin code required. + +--- + +## harden-cookies — Cookie security hardening / Secure HttpOnly SameSite attributes / Set-Cookie rewriting + +**What it solves:** A CDN filter that adds `Secure`, `HttpOnly`, and `SameSite=Strict` attributes to targeted `Set-Cookie` response headers. Eliminates the need to modify backend code to enforce cookie security policy at the edge. + +**How it deploys:** Single CDN Proxy-WASM app. Deployed via the Gcore portal template gallery (template ID 184). No origin changes required. + +**Runtime variants:** None. Behavior is controlled entirely through environment variables. + +**Targeting behavior:** Only cookies whose name matches `COOKIE_NAME` are modified. Use `*` to match all cookies. All other `Set-Cookie` headers pass through unchanged. If `COOKIE_NAME` is unset, or none of `SECURE`, `HTTPONLY`, `SAMESITE` is set to `true`, responses pass through untouched. + +**Attribute application:** `Secure` and `HttpOnly` are added only if not already present. `SameSite=Strict` is set unconditionally, overriding any existing `SameSite` value. + +**Origin integration:** Configured entirely through environment variables (`COOKIE_NAME`, `SECURE`, `HTTPONLY`, `SAMESITE`) — no origin code required. + +--- + +## edge-sso — SSO / login / identity federation / Identity-Aware Proxy (Google, GitHub, Microsoft, Facebook, SAML) + +**What it solves:** A bolt-on Identity-Aware Proxy that adds multi-provider SSO (Google, GitHub, Microsoft, Facebook, SAML 2.0) to any existing site without changing the backend. Handles OAuth 2.0 / OIDC / SAML flows, session token issuance, and per-request enforcement. Building this from scratch requires correctly implementing multiple OAuth flows, token signing, and edge enforcement — this template has already done that. + +**How it deploys:** Two FastEdge apps deployed together, both via the Gcore portal template gallery: +- `cdn-filter/` — CDN Proxy-WASM app (Rust); sits in the CDN proxy layer, verifies session token on every request, redirects unauthenticated users to the auth app +- `auth-app/` — HTTP app (TypeScript/Hono); federates to the identity provider, issues a signed session token, sets it on the client + +**Runtime variants:** `SSO_VARIANT` selects the identity-delivery mode — the same value must be set on both apps: + +| Variant | What the origin receives | When to use | +|---|---|---| +| `gate-only` | Allow/deny only — no identity forwarded | Origin needs access control but not user context | +| `cookie` | Signed JWT in a cookie the origin can verify | Origin reads user identity from a verifiable token | +| `header` | Signed `x-sso-*` identity headers injected upstream | Origin trusts a header from the CDN layer | + +**Supported identity providers:** Google (OAuth 2.0), GitHub (OAuth 2.0), Microsoft (OAuth 2.0 / OIDC), Facebook (OAuth 2.0), SAML (SAML 2.0). + +**Key shared configuration requirements:** `SSO_VARIANT` and `SSO_AUDIENCE` must match on both apps. `SESSION_SECRET` is required in every variant. The `cookie` variant additionally requires an EC keypair (`SESSION_SIGNING_KEY` secret + `SESSION_PUBLIC_JWK` env var). + +**Origin integration:** The template is configured through environment variables and secrets on both apps. For the customer-side wiring contract — how the origin validates tokens or trusts identity headers depending on the chosen variant — see the `edge-sso` integration reference. + +--- + +## edge-totp — TOTP MFA / two-factor authentication / OTP challenge / RFC 6238 + +**What it solves:** Adds a TOTP (RFC 6238) two-factor authentication step in front of an existing site's login without modifying the backend. The customer's origin handles password validation; this app hosts the 6-digit OTP challenge, verifies the code with replay and brute-force protection, and issues a signed session assertion the origin trusts. Building this from scratch requires correctly implementing HOTP/TOTP, replay protection, brute-force throttling, signed cookie issuance, and CDN enforcement — this template has already done that. + +**How it deploys:** Two FastEdge apps deployed together via the Gcore portal template gallery: +- `otp-app/` — HTTP app (TypeScript/Hono, WASM); hosts the challenge, verify, enroll, self-service activate, logout, JWKS, and health endpoints; signs the `mfa_session` cookie (HS256) and the optional ES256 proof +- `otp-filter/` — CDN Proxy-WASM app (Rust); enforces `mfa_session` on protected paths; default-deny and fail-closed + +**Runtime variants (enforcement profiles):** + +| Profile | Mode | What the origin must do | +|---|---|---| +| **A** (default) | Filter enforces, zero origin code | Nothing — the CDN layer is the enforcement boundary | +| **B** (opt-in) | Origin verifies a one-time ES256 proof via JWKS | Origin validates the proof at its own session boundary using the app's JWKS endpoint | + +**Security-critical deployment requirements:** +- The origin must be locked to edge-only traffic (IP allowlist / origin auth / tunnel) — the gate is bypassed if the origin is directly reachable +- `MFA_AUDIENCE` must be set on both apps; the filter fail-closes (rejects every session) if it is unset +- `GCORE_API_TOKEN` has write access to every seed in the KV store — scope it to a single-tenant, per-customer isolated KV store + +**CDN wiring:** Attach `otp-app` as a CDN origin on the `{AUTH_PREFIX}/*` path rule of the customer's CDN resource; attach `otp-filter` as the CDN proxy app in front of protected paths, bypassing `{AUTH_PREFIX}` and `/health`. Both share the CDN host so `mfa_session` is first-party host-only. + +**Origin integration:** For the full customer-side wiring contract, trust model, and Profile B JWKS integration, see the `edge-totp` integration reference. diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-sso-integration.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-sso-integration.md index 6051001..ee18756 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-sso-integration.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-sso-integration.md @@ -1,4 +1,214 @@ -# edge-sso — Origin Integration + -Placeholder — populated by the `fastedge-templates` auto-ref-update pipeline (see -`agent-intent-skills/fastedge-templates/templates/sso-integration.md`). Not yet generated. +# edge-sso — Origin Integration Reference + +Reference for origins integrating with an already-deployed `edge-sso` template pair (auth-app + cdn-filter). Covers the contract the origin relies on: identity delivery, auth routes, login page customization, and redirect validation. + +--- + +## 1. What `SSO_VARIANT` Means for the Origin + +`SSO_VARIANT` is set identically on both the auth-app and the cdn-filter. It controls how the edge delivers identity to the origin and what the origin is responsible for verifying. + +### `gate-only` + +- **What the origin receives**: nothing — the filter enforces allow/deny at the edge; only authenticated requests reach the origin. +- **Origin responsibility**: none. The origin does not receive a token or identity headers; it only sees requests that passed the gate. +- **Signing algorithm**: HS256 (shared `SESSION_SECRET`). The origin never verifies the token; the filter handles all verification. +- **Session cookie**: stripped before forwarding — the origin never sees the raw JWT. + +### `cookie` + +- **What the origin receives**: the `sso_session` cookie (configurable via `SESSION_COOKIE`) containing a signed JWT. The cookie passes through to the origin unchanged. +- **Origin responsibility**: the origin verifies the cookie itself using the published JWKS endpoint. + - **JWKS endpoint**: `GET /auth/.well-known/jwks.json` — mounted on the auth-app only when `SSO_VARIANT=cookie` and `SESSION_PUBLIC_JWK` is configured. Use a standard JWKS client (e.g., `createRemoteJWKSet` from the `jose` library) against this URL — do not implement verification manually. +- **Signing algorithm**: ES256 (asymmetric — EC private key `SESSION_SIGNING_KEY` signs; public JWK published via JWKS). The origin holds only the public key and verifies with it; it never holds a forge-capable secret. +- **Cookie attributes**: `HttpOnly; Secure; SameSite=Lax`. Under single-domain routing no `Domain=` is set — same-origin. +- **Token claims**: `sub`, `iat`, `exp`, `aud`, `iss` (optional), `email`, `name`, `picture`, `given_name`, `family_name`. + +### `header` + +- **What the origin receives**: verified identity injected as request headers. The session cookie is stripped before forwarding — the origin never sees the raw JWT. +- **Origin responsibility**: read and trust `x-sso-*` headers. The origin **must treat an empty `x-sso-*` header as absent** — the platform blanks a cleared header to empty rather than removing it, so an empty value means the claim was not present in the token. +- **Signing algorithm**: HS256 (shared `SESSION_SECRET`). The origin does not verify the token; the filter handles all verification before injecting headers. +- **Anti-spoofing contract**: the filter clears any client-supplied `x-sso-*` header before injecting verified values. A client cannot smuggle a spoofed identity header past the filter. The origin must not trust `x-sso-*` values from any path that bypasses the filter. + +**Complete list of injected headers (verbatim from `cdn-filter/src/lib.rs`):** + +| Header | Claim source | +|---|---| +| `x-sso-user` | `sub` | +| `x-sso-email` | `email` | +| `x-sso-name` | `name` | +| `x-sso-picture` | `picture` | +| `x-sso-given-name` | `given_name` | +| `x-sso-family-name` | `family_name` | + +Headers for claims absent from the token: if the claim was not present, the header is absent (or empty — treat empty as absent per the platform contract above). + +--- + +## 2. Auth-App Routes + +All routes are served under `AUTH_PREFIX` (default: `/auth`). Under single-domain routing, the CDN routes `AUTH_PREFIX/**` to the auth-app as an origin on the customer's own domain. The cdn-filter bypasses `AUTH_PREFIX/**` — it does not gate these routes. + +| Route | Method | Caller | Purpose | +|---|---|---|---| +| `/auth/` and `/auth` | GET | Browser | Hosted login page — server-rendered, branded, provider buttons. Honours `?redirect=`. | +| `/auth/providers` | GET | Browser / custom login UI | Provider data (JSON) — the enabled provider set with login URLs. Honours `?redirect=`. | +| `/auth/branding` | GET | Custom login page | Branding config (JSON) — current `LOGIN_PAGE_*` values for custom pages. | +| `/auth/login/google` | GET | Browser | Start Google OIDC. Honours `?redirect=`. | +| `/auth/login/github` | GET | Browser | Start GitHub OAuth. Honours `?redirect=`. | +| `/auth/login/microsoft` | GET | Browser | Start Microsoft OIDC. Honours `?redirect=`. | +| `/auth/login/facebook` | GET | Browser | Start Facebook OAuth. Honours `?redirect=`. | +| `/auth/login` | GET | Browser | Start SAML SSO. Honours `?redirect=`. | +| `/auth/logout` | GET | Browser | Sign out — clears `sso_session` (`Max-Age=0`), redirects to the validated `?redirect=` (defaults to `/`). Not gated by the filter. | +| `/auth/callback/` | GET | IdP only | OAuth/OIDC callback — used by the identity provider, not called directly. | +| `/auth/callback` | POST | IdP only | SAML ACS endpoint — used by the identity provider, not called directly. | +| `/auth/.well-known/jwks.json` | GET | Origin / JWKS client | Public JWK set — mounted only when `SSO_VARIANT=cookie` and `SESSION_PUBLIC_JWK` is set. | + +`?redirect=` is the post-login destination. After successful federation the auth-app sets the `sso_session` cookie and 302s to that URL. + +--- + +## 3. Login Page Customization Tiers + +### Tier 1 — Env Var Branding (recommended default) + +The built-in hosted login page reads these env vars per-request. No code changes required. + +| Env var | Default | Effect | +|---|---|---| +| `LOGIN_PAGE_TITLE` | `"Sign in"` | `` and `<h1>` | +| `LOGIN_PAGE_SUBTITLE` | `"Choose a sign-in method"` | Subheading below the title | +| `LOGIN_PAGE_LOGO_URL` | — | Logo image above the title | +| `LOGIN_PAGE_FAVICON_URL` | — | Tab favicon | +| `LOGIN_PAGE_ACCENT_COLOR` | `#0066cc` | Button/focus-ring color (CSS `--lp-accent`) | +| `LOGIN_PAGE_BACKGROUND_COLOR` | `#f0f2f5` | Page background (CSS `--lp-bg`) | +| `LOGIN_PAGE_CSS_URL` | — | Customer stylesheet linked last — overrides any built-in style | +| `IDP_LABEL` | `"SSO"` | Display name for the SAML provider button | +| `IDP_ICON_URL` | — | Icon URL for the SAML provider button | + +`LOGIN_PAGE_CSS_URL` is the deep-customization escape hatch — a `<link rel="stylesheet">` injected after built-in styles. The CSS variables `--lp-accent` and `--lp-bg` are intentional override points. + +### Tier 2 — Fully Custom Login Page (`LOGIN_PAGE_URL`) + +Set `LOGIN_PAGE_URL` on the **CDN filter** to redirect unauthenticated users to a page you own instead of the built-in one. That page calls `GET /auth/providers` for login URLs and, optionally, `GET /auth/branding` for consistent branding tokens. + +``` +LOGIN_PAGE_URL=https://shop.example.com/my-login +``` + +Your custom page handles the full UI; clicking a provider's button navigates to its `loginUrl` (relative, same-origin) which kicks off the standard federation flow. The default value of `LOGIN_PAGE_URL` is `/auth/` — set it only to opt out. + +### Tier 3 — Embed Sign-In Buttons on an Existing Page + +**Static links (simplest):** hard-code the provider routes. +```html +<a href="/auth/login/google?redirect=/account">Sign in with Google</a> +<a href="/auth/login/github?redirect=/account">Sign in with GitHub</a> +<a href="/auth/login?redirect=/account">Single Sign-On</a> +``` + +**Dynamic widget:** fetch `/auth/providers` and render whatever is enabled. +```js +const { providers } = await fetch("/auth/providers?redirect=/account").then(r => r.json()); +for (const p of providers) { + const a = document.createElement("a"); + a.href = p.loginUrl; // relative, same-origin, redirect already encoded + a.textContent = `Sign in with ${p.label}`; + loginContainer.append(a); +} +``` + +Adding or removing a provider (a secret or `SSO_PROVIDERS` change in the portal) updates the widget with no code change on the customer's side. + +--- + +## 4. JSON Contracts + +### `GET /auth/providers` + +```jsonc +// GET /auth/providers?redirect=/cart +{ + "providers": [ + { "id": "google", "label": "Google", "loginUrl": "/auth/login/google?redirect=%2Fcart" }, + { "id": "github", "label": "GitHub", "loginUrl": "/auth/login/github?redirect=%2Fcart" }, + { "id": "saml", "label": "SSO", "loginUrl": "/auth/login?redirect=%2Fcart" } + ] +} +``` + +- `id` — stable identifier; matches the value used in `SSO_PROVIDERS`. +- `label` — human label; the SAML label is overridden by `IDP_LABEL`. +- `loginUrl` — relative path including the encoded `redirect`. +- Order is stable (registry order), not allowlist order. +- The enabled provider set is resolved at runtime from `SSO_PROVIDERS` ∩ providers-whose-credentials-are-present. + +### `GET /auth/branding` + +```jsonc +{ + "title": "Sign in", + "subtitle": "Choose a sign-in method", + "logoUrl": "https://cdn.example.com/logo.png", + "faviconUrl": null, + "accentColor": "#e00", + "backgroundColor": "#f0f2f5", + "cssUrl": null +} +``` + +Returns the current `LOGIN_PAGE_*` env var values as a JSON object. Custom login pages (Tier 2) can `fetch("/auth/branding")` to auto-style themselves consistently without duplicating the env var set. + +--- + +## 5. Redirect Validation + +The `?redirect=` parameter is validated against `SSO_ALLOWED_ORIGINS` on every route that honours it and on every read of the `saml_relay` cookie. + +**Rules:** +- Relative URLs (starting with `/`) are always permitted. +- Off-origin absolute URLs are silently dropped — the post-login redirect falls back to `/`. +- Protocol-relative and backslash bypasses (e.g., `/\evil.com`) are rejected. +- To permit absolute redirects to specific origins, set `SSO_ALLOWED_ORIGINS` to a comma-separated list of allowed origins (e.g., `https://shop.example.com`). + +This is a security-load-bearing constraint, not cosmetic. An incorrect or missing `SSO_ALLOWED_ORIGINS` will silently drop redirect parameters that include absolute URLs, changing post-login destination behavior without error. + +--- + +## 6. Shared Config the Origin Needs to Know About + +These env vars affect the contract the origin operates under. They are set on the FastEdge apps (auth-app and/or cdn-filter), but the origin integration depends on their values. + +| Env var | Set on | Origin concern | +|---|---|---| +| `SSO_VARIANT` | Both apps (must match) | Determines what the origin receives: nothing (gate-only), a JWT cookie to verify (cookie), or identity headers (header). | +| `SSO_AUDIENCE` | Both apps (must match) | The `aud` claim in every minted token. The cookie variant origin must validate `aud` matches this value when verifying the JWT. | +| `AUTH_PREFIX` | Both apps | The path prefix reserved for auth routes (default: `/auth`). The origin must not serve conflicting routes under this prefix; the CDN routes it to the auth-app. | +| `SESSION_COOKIE` | Both apps | Cookie name (default: `sso_session`). The cookie variant origin reads the session token from this cookie name. | +| `SESSION_SECRET` | Auth-app + cdn-filter (gate-only/header) | HS256 signing secret. The origin does not use this directly; only the filter verifies with it. | +| `SESSION_SIGNING_KEY` / `SESSION_PUBLIC_JWK` | Auth-app (cookie variant) | EC keypair. The origin uses the JWKS endpoint (`GET /auth/.well-known/jwks.json`) — never `SESSION_SIGNING_KEY` directly. | +| `SSO_ALLOWED_ORIGINS` | Auth-app | Governs which absolute redirect URLs are permitted. Affects post-login destination. | +| `LOGIN_PAGE_URL` | CDN filter | Where unauthenticated users are redirected. Default `/auth/`. Set to opt out of the built-in hosted page. | +| `CANONICAL_HOST` | Auth-app | The auth-app 301-redirects requests arriving on non-canonical hosts. The origin and IdP callback URLs must use this domain. | + +**Provider credentials** (`GOOGLE_CLIENT_ID`, `GITHUB_CLIENT_ID`, `MICROSOFT_CLIENT_ID`, `FACEBOOK_CLIENT_ID`, SAML `IDP_*`/`SP_*`) are internal to the auth-app and not visible to the origin. Provider availability at runtime is reflected in `GET /auth/providers`. + +--- + +## See Also + +- edge-sso template README (deployment and configuration of the auth-app and cdn-filter) +- auth-app `.env.example` and cdn-filter `.env.example` (authoritative, exhaustive env var lists with inline guidance) +- edge-sso architecture: auth-modes (SSO_VARIANT axis detail) +- edge-sso architecture: security (token trust model, known limitations including no revocation and no IdP Single Logout) +- edge-sso architecture: overview (two-app deployment model, signing strategy, CANONICAL_HOST) diff --git a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-totp-integration.md b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-totp-integration.md index 9785bf8..4b3a82a 100644 --- a/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-totp-integration.md +++ b/plugins/gcore-fastedge/skills/fastedge-docs/reference/templates/edge-totp-integration.md @@ -1,4 +1,120 @@ +<!-- + auto-updated: true + sources: + - id: fastedge-templates + ref: main + commit: 87b7dc143db5e74cf0e7eb52f67484f6abc51c43 + updated: 2026-08-17 +--> + # edge-totp — Origin Integration -Placeholder — populated by the `fastedge-templates` auto-ref-update pipeline (see -`agent-intent-skills/fastedge-templates/templates/totp-integration.md`). Not yet generated. +Reference for wiring the TOTP second-factor step into an origin's existing password login. The `edge-totp` template (otp-app + otp-filter) is already deployed on the Gcore portal; this document covers the three origin-side code changes required to connect it. + +--- + +## The Three Origin-Side Changes + +Only the origin's auth module changes. The rest of the site is untouched. + +### 1. Split login into password-then-OTP + +After the password check succeeds, instead of immediately minting the full origin session: + +1. Sign a **handoff ticket** = `HMAC(HANDOFF_KEY)` over `{ sub: userId, next, exp: now + TICKET_TTL }`. +2. Set a short-lived **`pre_mfa`** cookie or marker so the origin remembers the password step passed (bind it to `sub`). +3. Issue a `303` redirect to `{AUTH_PREFIX}/challenge?t=<ticket>` (totp-app, same host via the CDN path rule). + +**Ticket constraints:** +- `sub` — the user identifier +- `next` — the URL to return to after MFA +- `exp` — absolute expiry (`now + TICKET_TTL`); totp-app independently requires this field and caps absolute age +- Signed with `HANDOFF_KEY` (HS256, shared between origin and edge) + +### 2. Finish login on return — choose a profile + +When the user returns to `next` after the TOTP step, the origin finishes minting its session. Pick one profile: + +**Profile A (default — zero origin crypto):** +- The Rust filter has already enforced `mfa_session` on protected paths before the request reaches the origin. +- The origin re-identifies its `pre_mfa` user and mints its own session — no proof verification, no crypto on the origin side. +- `HANDOFF_KEY` is the only key the origin holds. + +**Profile B (opt-in — longer sessions, signed proof):** +- The edge delivers a one-time **ES256** proof as a short-lived cookie (`MFA_PROOF_COOKIE`). The proof is never in a URL. +- The origin verifies the proof via totp-app's JWKS endpoint (`{AUTH_PREFIX}/.well-known/jwks.json`) using `createRemoteJWKSet` (public key only — the origin cannot forge proofs). +- The origin checks that the proof's `sub` matches `pre_mfa`. +- **Required:** the origin must also verify the proof's `aud` claim against `MFA_AUDIENCE`. Verifying the signature alone without checking `aud` accepts a proof that was never meant for this deployment — this is a required check, not optional. +- After verification, the origin mints its own revocable session with whatever lifetime it needs (safely longer than the 8h edge session). + +### 3. Enroll users + +No new origin endpoint is required. Two paths: + +- **Admin provisioning:** `POST {AUTH_PREFIX}/enroll` (gated by `ENROLL_API_KEY`). totp-app writes the seed to KV using `GCORE_API_TOKEN`. Call with `force: true` to re-provision a lost authenticator behind your own identity check. +- **Self-service:** Users can self-enroll on first login via `{AUTH_PREFIX}/activate`. Enabled by default (`ALLOW_SELF_ENROLLMENT=true`). Set `ALLOW_SELF_ENROLLMENT=false` to require admin provisioning; `/activate` returns 403 and `/challenge` refuses unenrolled users. + +--- + +## Profile A vs Profile B — Decision Guide + +This is a security-posture decision. A wrong choice here is a real vulnerability, not a style issue. + +| Criterion | Profile A | Profile B | +| --- | --- | --- | +| Origin crypto required | None — `HANDOFF_KEY` only | ES256 proof verification via JWKS | +| Origin knows *which* user passed MFA | No — the filter only verifies that *a* valid `mfa_session` exists | Yes — the ES256 proof carries `sub`; origin verifies and binds it to `pre_mfa` | +| Session lifetime | Edge-bounded (8h, non-sliding) | Origin mints its own session at whatever lifetime it needs | +| Origin-lockdown dependency | Hard requirement — Profile A collapses entirely if the origin is directly reachable | More robust — origin independently verifies the signed proof rather than trusting the request came through the gate | + +**Do not add an unsigned forwarded-identity header for the origin to trust under Profile A.** Forwarded-identity headers (`x-mfa-user` or similar) are a recurring source of auth-bypass CVEs (e.g. oauth2-proxy header smuggling). The Rust filter deliberately does not forward the user id to the origin because the origin already authenticated the password and re-identifies its `pre_mfa` user when minting its session. If you need the edge to assert *which* user passed MFA, use Profile B: the ES256 proof carries `sub`, the origin verifies it via JWKS, and the proof is signed rather than a bare header. This is the same signed-assertion pattern Cloudflare Access uses (`Cf-Access-Jwt-Assertion`). + +**Profile B `aud` check is mandatory.** After verifying the ES256 signature via JWKS, also assert that the proof's `aud` claim equals `MFA_AUDIENCE`. The edge embeds `MFA_AUDIENCE` as `aud` in both `mfa_session` and the Profile-B proof. `MFA_AUDIENCE` should be the CDN hostname (e.g. `https://app.example.com`). A proof signed by this deployment's private key but addressed to a different audience must be rejected. + +**Prefer Profile B when the origin cannot be fully locked to edge-only traffic.** Profile B independently verifies a signed assertion at the origin; Profile A relies entirely on the filter gate having run. + +--- + +## Shared Configuration + +Keys and endpoints shared between the origin and the edge. Both components must agree on these values. + +| Key / Endpoint | Origin | Edge | +| --- | --- | --- | +| `HANDOFF_KEY` (HS256) | Signs the handoff ticket on password success | Verifies the ticket on `{AUTH_PREFIX}/challenge` and `{AUTH_PREFIX}/verify` | +| **JWKS** endpoint (Profile B only) | Fetches `{AUTH_PREFIX}/.well-known/jwks.json` via `createRemoteJWKSet` (public key only — cannot forge proofs) | Signs the one-time ES256 proof with `MFA_PROOF_SIGNING_KEY` (private key, edge-internal); serves the JWKS endpoint | +| `MFA_AUDIENCE` | **Must** enforce as the `aud` claim on the Profile-B proof (required check after signature verification) | Embedded as `aud` in both `mfa_session` and the Profile-B proof; the Rust filter enforces it on `mfa_session` (Profile A); fail-closes without it | + +**Profile A shares only `HANDOFF_KEY`.** The origin holds no verification key for `mfa_session`; that is edge-internal (signed with `MFA_SESSION_KEY`, verified by the Rust filter). No symmetric secret crosses to the origin on the proof path under Profile B — only the JWKS public-key fetch. + +Both components must live on the **same CDN host** (CDN path rule `{AUTH_PREFIX}/*` → totp-app). The `mfa_session` cookie is first-party and host-only. Same-host is required: the proof and session are consumed at the edge into a host-only cookie, so there is no cross-host URL-token path. + +--- + +## Required Deployment Precondition + +**Lock the origin to edge-only traffic.** This is a hard requirement for both profiles, not a suggestion. + +Any edge gate — Profile A or B — is meaningless if the origin is directly reachable. If the origin's IP or hostname is accessible without going through the Gcore CDN, an attacker simply skips the CDN entirely; `mfa_session`, the Rust filter, and the signed proof never run. Restrict the origin to Gcore CDN ingress using an IP allowlist, origin authentication, or a tunnel. + +Profile B is more robust in environments where the origin cannot be fully locked down (the origin independently verifies the signed proof rather than trusting that the request arrived through the gate), but it does not eliminate this requirement — locking the origin is still necessary. + +--- + +## Recovery and Self-Service Enrollment Caveat + +Self-service enrollment (`{AUTH_PREFIX}/activate`) inherits the trust level of the password step — a user who can pass the password check can self-enroll a new authenticator. This is the correct default for most deployments, but for sensitive accounts this means a compromised password also compromises the TOTP second factor. + +**Decision point for sensitive accounts:** If your threat model requires that the second factor remain independent of the password (e.g. privileged users, admin accounts), disable self-service enrollment (`ALLOW_SELF_ENROLLMENT=false`) and require admin provisioning via `POST {AUTH_PREFIX}/enroll` (gated by `ENROLL_API_KEY`, behind your own identity check). Recovery for a lost authenticator also goes through `POST {AUTH_PREFIX}/enroll` with `force: true`. + +See the `edge-totp` threat model reference (threat-model.md, risk R5) before relying on self-service enrollment for sensitive accounts. + +--- + +## See Also + +- edge-totp storage and secrets reference (storage-and-secrets.md) — full env var and secret list for both otp-app and otp-filter +- edge-totp threat model reference (threat-model.md) — trust boundaries, residual risks, and risk register including R4 (KV token scope) and R5 (self-enrollment trust level) +- edge-totp architecture flow reference (flow.md) — why the seed is fetched at verify time and PoP replication behavior +- FastEdge KV store reference — `fastedge::kv` SDK binding and `storeRefs`/`kvStoreVars` deploy-time linking +- FastEdge secrets reference — `getSecret` API and dotenv sync workflow