docs: add ForwardAuth Redirection & Deep-Link Protocol Specification (RFC-SPEC-2026-FA-01)

This commit is contained in:
Tyler Gillispie 2026-08-24 12:53:16 -07:00
parent 96bcf69179
commit b867a166e9
2 changed files with 176 additions and 0 deletions

View File

@ -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).

View File

@ -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=<encoded_target_url>
HTTP 200 OK + Grant Vector Injection
• X-Forwarded-User-Id: <uuid>
• X-Forwarded-User: <username>
• X-Forwarded-Scopes: <scopes>
• X-Forwarded-App-Id: <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`<br>`X-Forwarded-Host: ed-droid.atyg.org` | `302 Found` | `Location: https://auth.atyg.org/login?redirect=...` |
| **API/cURL Unauthenticated** | `Accept: application/json` or `*/*`<br>`X-Forwarded-Host: ed-droid.atyg.org` | `401 Unauthorized` | Body: `"Unauthorized"` |
| **Browser Authenticated (Valid Grant)** | `Cookie: session_id=...`<br>`X-Forwarded-Host: ed-droid.atyg.org` | `200 OK` | `X-Forwarded-User: alice`<br>`X-Forwarded-Scopes: operator` |
| **Browser Authenticated (No Grant)** | `Cookie: session_id=...`<br>`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) |