diff --git a/README.md b/README.md index 6052b71..7f30f0a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ workload identity. freezes and in-flight WebAuthn re-auth. - _Raw Gitea Mirror:_ `https://git.atyg.org/tylerg/auth-yes/raw/branch/main/docs/GHOST_COCKPIT_SPEC.md` +- 🌐 + **[ForwardAuth Browser Redirection & Deep-Link Spec](docs/FORWARDAUTH_REDIRECT_SPEC.md):** + Specification for browser 302 vs API 401 dual-response handling, open redirect + prevention (CWE-601), and deep-link query parameter preservation. + - _Raw Gitea Mirror:_ + `https://git.atyg.org/tylerg/auth-yes/raw/branch/main/docs/FORWARDAUTH_REDIRECT_SPEC.md` - πŸ›‘οΈ **[Cryptographic Standards & Verification Dossier](docs/VERIFY.md):** Full mathematical and specification audits (W3C WebAuthn Level 3, RFC 9421, RFC 7638, RFC 6962, RFC 9106). diff --git a/docs/FORWARDAUTH_REDIRECT_SPEC.md b/docs/FORWARDAUTH_REDIRECT_SPEC.md new file mode 100644 index 0000000..2d35aaa --- /dev/null +++ b/docs/FORWARDAUTH_REDIRECT_SPEC.md @@ -0,0 +1,170 @@ +# ForwardAuth Browser Redirection & Deep-Link Protocol Specification + +**Specification ID:** RFC-SPEC-2026-FA-01\ +**Classification:** Ingress Security & UX Protocol\ +**Applies To:** Traefik Ingress Proxy, Auth-Yes Core Gateway +(`/api/forward-auth`), WebAuthn Client (`auth-client.js`)\ +**Status:** Canonical / Implemented + +--- + +## 1. Executive Summary & Problem Statement + +When edge ingress proxies (e.g. Traefik) intercept traffic via **ForwardAuth**, +unauthenticated requests must be handled differently depending on the nature of +the client: + +1. **Interactive Web Browsers (Humans):** Navigating to a protected web + application (e.g. `https://ed-droid.atyg.org/control-panel?tab=telemetry`) + must **not** display a blank `401 Unauthorized` text error. The proxy must + seamlessly issue a `302 Found` redirect to the WebAuthn Passkey authority + (`https://auth.atyg.org/login?redirect=...`) and automatically return the + user to their exact requested URL/tab upon biometric touch. +2. **Headless Daemons & REST APIs (Machines):** Automated daemons, cURL scripts, + and microservice RPCs must **never** receive an HTML redirect loop. They must + receive an immediate, machine-parseable HTTP `401 Unauthorized` (or + `403 Forbidden`). + +--- + +## 2. The Dual-Response Protocol (Browser 302 vs API 401) + +### Detection Logic in `GET /api/forward-auth` + +When Auth-Yes receives an unauthenticated ForwardAuth sub-request from Traefik: + +``` +[ Incoming Request to Ingress ] + β”‚ + β–Ό +[ Traefik ForwardAuth Sub-request to auth-api ] + - Headers evaluated: + β€’ X-Forwarded-Host: "ed-droid.atyg.org" + β€’ X-Forwarded-Uri: "/control-panel?tab=telemetry" + β€’ X-Forwarded-Proto: "https" + β€’ Accept: "text/html,application/xhtml+xml,..." + β”‚ + β–Ό + Is Session Valid in Valkey/DB? + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + YES NO + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β–Ό β–Ό + β”‚ Accept header contains Accept header does NOT + β”‚ "text/html" (Browser) contain "text/html" (API) + β”‚ β”‚ β”‚ + β”‚ β–Ό β–Ό + β”‚ HTTP 302 Redirect HTTP 401 Unauthorized + β”‚ Location: Body: "Unauthorized" + β”‚ https://auth.atyg.org/login? + β”‚ redirect= + β”‚ + β–Ό +HTTP 200 OK + Grant Vector Injection + β€’ X-Forwarded-User-Id: + β€’ X-Forwarded-User: + β€’ X-Forwarded-Scopes: + β€’ X-Forwarded-App-Id: +``` + +--- + +## 3. Deep-Link & Parameter Preservation + +To prevent user friction, the full destination URLβ€”including protocol, host, +path, and all query parametersβ€”is preserved throughout the authentication +ceremony: + +1. **URL Reconstruction at Ingress:** + ```typescript + const host = req.headers.get("X-Forwarded-Host") || "atyg.org"; + const proto = req.headers.get("X-Forwarded-Proto") || "https"; + const uri = req.headers.get("X-Forwarded-Uri") || "/"; + const targetUrl = `${proto}://${host}${uri}`; + ``` +2. **Encoding into Redirect Location:** + ```http + HTTP/1.1 302 Found + Location: https://auth.atyg.org/login?redirect=https%3A%2F%2Fed-droid.atyg.org%2Fcontrol-panel%3Ftab%3Dtelemetry + ``` +3. **Post-Authentication Dispatch:** Immediately following WebAuthn passkey + verification, `auth-client.js` extracts the `redirect` search parameter and + executes: + ```javascript + globalThis.location.href = targetRedirect; + ``` + +--- + +## 4. Open Redirect Security (CWE-601 Prevention) + +Allowing arbitrary redirect parameters poses severe phishing risks (e.g. +`https://auth.atyg.org/login?redirect=https://evil-attacker.com`). + +### Strict Whitelist Validation Rules: + +Before dispatching the user post-login, the `redirect` URL **MUST** pass strict +origin validation: + +```typescript +function isSafeRedirectUrl(rawUrl: string): boolean { + try { + // 1. Relative paths within the same origin are always safe + if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) { + return true; + } + + const parsed = new URL(rawUrl); + + // 2. Only allow explicit wildcard *.atyg.org subdomains or localhost + if ( + parsed.hostname === "atyg.org" || + parsed.hostname.endsWith(".atyg.org") || + parsed.hostname === "localhost" + ) { + return true; + } + } catch (_e) { + // Malformed URL + return false; + } + return false; +} +``` + +- **Violation Behavior:** If an invalid or foreign domain is supplied in the + `redirect` parameter, the client **silently falls back to `/dashboard`**, + preventing malicious redirects. + +--- + +## 5. Infinite Redirect Loop Prevention & 403 Forbidden Handling + +A critical failure mode in ForwardAuth occurs when an authenticated user +attempts to access an application for which they lack an active RBAC grant: + +- **The Anti-Pattern (Infinite Loop):** Returning `302 Redirect` to `/login` + when a user _is already logged in_ causes the browser to loop infinitely + between `app.atyg.org` and `auth.atyg.org`. +- **The Auth-Yes Rule:** + - **Unauthenticated (`!auth`):** Return `302` to `/login` (for browsers) or + `401` (for APIs). + - **Authenticated but Inactive / Suspended (`account_status !== 'active'`):** + Return `403 Forbidden` ("Forbidden: Account inactive"). + - **Authenticated but Lacking Role Grant (`!grantRole && !isGlobalAdmin`):** + Return `403 Forbidden` ("Forbidden: Access denied to this application"). + +--- + +## 6. Verification Test Cases + +| Scenario | Request Headers | Expected HTTP Status | Response Header / Body | +| :-------------------------------------- | :--------------------------------------------------------------------------- | :---------------------------- | :---------------------------------------------------------- | +| **Browser Unauthenticated** | `Accept: text/html`
`X-Forwarded-Host: ed-droid.atyg.org` | `302 Found` | `Location: https://auth.atyg.org/login?redirect=...` | +| **API/cURL Unauthenticated** | `Accept: application/json` or `*/*`
`X-Forwarded-Host: ed-droid.atyg.org` | `401 Unauthorized` | Body: `"Unauthorized"` | +| **Browser Authenticated (Valid Grant)** | `Cookie: session_id=...`
`X-Forwarded-Host: ed-droid.atyg.org` | `200 OK` | `X-Forwarded-User: alice`
`X-Forwarded-Scopes: operator` | +| **Browser Authenticated (No Grant)** | `Cookie: session_id=...`
`X-Forwarded-Host: ungranted-app.atyg.org` | `403 Forbidden` | Body: `"Forbidden: Access denied..."` (No redirect loop) | +| **Open Redirect Exploit Attempt** | `?redirect=https://phishing-site.com` | `200 OK` $\rightarrow$ Client | Navigates to `/dashboard` (Phishing domain discarded) |