diff --git a/docs/TIER1_INGRESS_SPEC.md b/docs/TIER1_INGRESS_SPEC.md new file mode 100644 index 0000000..7240ec6 --- /dev/null +++ b/docs/TIER1_INGRESS_SPEC.md @@ -0,0 +1,91 @@ +# Tier 1 Universal Global Edge Ingress Protection Specification + +**Specification ID:** RFC-SPEC-2026-INGRESS-01 **Classification:** Ingress +Security & Traefik Routing **Status:** Canonical / Implemented + +--- + +## 1. Executive Summary + +This specification outlines the Tier 1 Ingress Control strategy using Traefik's +`ForwardAuth` middleware combined with a dynamic Valkey-backed authorization +matrix within `auth-api`. The system provides highly performant (microsecond +latency) dynamic bypasses, strict SSR unregistered fallbacks, and router-level +self-exemptions. + +--- + +## 2. Traefik Entrypoint Configuration + +To enforce zero-trust global edge protection, the `ForwardAuth` middleware is +bound directly to the global HTTPS entrypoint. This ensures that _every_ service +routed through Traefik is automatically intercepted without relying on +developers to attach middleware to individual container labels. + +### Global ForwardAuth Middleware + +```yaml +# infra/compose.yml snippet (Traefik labels) +services: + traefik: + labels: + - "traefik.http.middlewares.auth-forward.forwardauth.address=http://auth-api:3000/api/forward-auth" + - "traefik.http.middlewares.auth-forward.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.auth-forward.forwardauth.authResponseHeaders=X-Forwarded-User,X-Forwarded-User-Id,X-Forwarded-Scopes,X-Forwarded-App-Id" +``` + +--- + +## 3. Router-Level Self-Exemptions + +Applying ForwardAuth globally creates an infinite redirect deadlock if +`auth-api` intercepts requests destined for its own login mechanisms. To resolve +this, explicit routes must be exempted at the Traefik router level by +intentionally _not_ attaching the `auth-forward` middleware or configuring a +bypass. + +### Required Exemptions + +1. **Authentication API / UI (`auth.atyg.org`)** + - The central Identity Provider must be explicitly bypassed. +2. **ACME Challenge (`/.well-known/acme-challenge/*`)** + - Let's Encrypt automated HTTP-01 certificate renewals must proceed + unauthenticated. +3. **Global Health Checks (`/healthz`)** + - Orchestration systems must be able to verify container readiness. + +--- + +## 4. Auth-API Dynamic Bypass Rules + +Instead of volatile Traefik HTTP Dynamic Providers, Auth-Yes maintains a +highly-performant cache in Valkey (L1/L2) under the key +`auth:app_by_host:`. When a request arrives at `/api/forward-auth`, the +gateway evaluates the following dynamic properties before validating sessions: + +1. **`is_public` (Boolean)** + - If true, the entire application domain bypasses session checks. +2. **`bypass_paths` (List of Strings)** + - Fast deterministic prefix matching (`/api/public/*`) and exact matching + (`/webhook`). +3. **`allowed_cidrs` (List of Strings)** + - Fast native Deno IPv4 subnet matching to allowlist specific networks (e.g., + internal CI/CD). + +If any of the above rules evaluate to true, `auth-api` immediately returns +`200 OK` (with `X-Forwarded-App-Id` injected). + +--- + +## 5. Unregistered Application Protocol + +If a domain is not registered in the central `apps` table (and therefore not in +the Valkey cache), `auth-api` strictly denies the request. + +- **Browser Access (`Accept: text/html`):** The proxy returns an HTTP + `302 Redirect` to `https://auth.atyg.org/errors/unregistered?host=`. +- **API Access (Non-HTML):** The proxy returns an HTTP `403 Forbidden` + (`{"error": "Application not registered"}`). + +This mechanism explicitly prevents open-redirect and infrastructure mapping +attacks. diff --git a/server/auth-session.ts b/server/auth-session.ts index 2c7a3df..fb3bef5 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -9,6 +9,15 @@ export interface AuthenticatedUser { username: string; } +export interface AppRecord { + id: string; + name: string; + domain?: string; + is_public?: boolean; + bypass_paths?: string[]; + allowed_cidrs?: string[]; +} + /** * Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org) * to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.). @@ -96,7 +105,7 @@ export async function getAuthenticatedUser( */ export async function getAppByHost( host: string, -): Promise<{ id: string; name: string } | null> { +): Promise { const cacheKey = `auth:app_by_host:${host}`; // 1. Try Valkey cache @@ -113,7 +122,8 @@ export async function getAppByHost( try { // Search by domain first let app = await sqlWrapper.sql` - SELECT id, name FROM apps WHERE domain = ${host} + SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs + FROM apps WHERE domain = ${host} `.then((res: any) => res[0]); // Fallback: match name against the first subdomain segment @@ -121,7 +131,8 @@ export async function getAppByHost( const subdomain = host.split(".")[0]; if (subdomain) { app = await sqlWrapper.sql` - SELECT id, name FROM apps WHERE name = ${subdomain} + SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs + FROM apps WHERE name = ${subdomain} `.then((res: any) => res[0]); } } @@ -131,7 +142,7 @@ export async function getAppByHost( try { await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour } catch (_e) {} - return app; + return app as AppRecord; } } catch (_err) { return null; @@ -219,6 +230,75 @@ export async function isGlobalAdmin(userId: string): Promise { } /** + * Deterministic fast path prefix matcher for dynamic bypasses + */ +export function isPathBypassed( + requestPath: string, + bypassPaths?: string[], +): boolean { + if (!bypassPaths || bypassPaths.length === 0) return false; + + for (const pattern of bypassPaths) { + if (pattern === requestPath) return true; + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -2); // remove /* + if (requestPath === prefix || requestPath.startsWith(prefix + "/")) { + return true; + } + } + } + + return false; +} + +/** + * Pure native Deno bitwise CIDR matcher + */ +export function isIpAllowed( + clientIp: string, + allowedCidrs?: string[], +): boolean { + if (!allowedCidrs || allowedCidrs.length === 0) return false; + + // Basic IP parsing (v4 only for simplicity and speed, or basic v6 check) + const parseIp4 = (ip: string) => { + const parts = ip.split("."); + if (parts.length !== 4) return null; + return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> + 0; + }; + + // X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2") + // We extract the first IP (the original client) + const primaryIp = clientIp.split(",")[0].trim(); + const ipNum = parseIp4(primaryIp); + + for (const cidr of allowedCidrs) { + const [subnet, maskStr] = cidr.split("/"); + if (!maskStr) { + if (subnet === primaryIp) return true; + continue; + } + + // IPv4 CIDR matching + if (ipNum !== null && subnet.includes(".")) { + const subnetNum = parseIp4(subnet); + if (subnetNum !== null) { + const maskBits = parseInt(maskStr, 10); + // Fix for /0 masks to avoid JS bitwise shift 32 overflow masking + const mask = maskBits === 0 ? 0 : ((0xffffffff << (32 - maskBits)) >>> 0); + if ((ipNum & mask) === (subnetNum & mask)) { + return true; + } + } + } // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR. + // Full IPv6 CIDR math would be added here if needed, but string equality + // works for exact IPv6 matches. + else if (subnet === clientIp) { + return true; + } + } + * Validates a given URL to ensure it is safe to redirect to. * Allows relative paths, localhost, and *.atyg.org (or custom RP_ID) */ diff --git a/server/db.ts b/server/db.ts index bce0282..35ed4c0 100644 --- a/server/db.ts +++ b/server/db.ts @@ -69,6 +69,15 @@ export async function initDb(): Promise { // Soft ignore if column already exists } + // Tier 1 Ingress Control + try { + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE`; + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS bypass_paths TEXT[] DEFAULT '{}'`; + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS allowed_cidrs TEXT[] DEFAULT '{}'`; + } catch { + // Soft ignore if columns already exist + } + await sql` CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/server/main.test.ts b/server/main.test.ts index c99d4e2..9d5afb7 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -371,7 +371,7 @@ Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => { const res = await app.fetch(req); assertEquals(res.status, 400); const json = await res.json(); - assertEquals(json.error, "inviteCode required"); + assertEquals(json.error, "inviteCode or upgrade_session required"); }); Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => { @@ -422,6 +422,104 @@ Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () = Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => { const { getCookieDomain } = await import("./auth-session.ts"); + assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("localhost"), undefined); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (API)", async () => { + // Override sql to return no app + sqlWrapper.sql = (async () => []) as any; + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "unknown.atyg.org", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const data = await res.json(); + assertEquals(data.error, "Application not registered"); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Unregistered domain (Browser)", async () => { + // Override sql to return no app + sqlWrapper.sql = (async () => []) as any; + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "unknown.atyg.org", + "Accept": "text/html", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 302); + const location = res.headers.get("Location") || ""; + assertEquals( + true, + location.includes("/errors/unregistered?host=unknown.atyg.org"), + ); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (is_public)", async () => { + // Override sql to return public app + sqlWrapper.sql = (async (strings: any) => { + if (strings[0].includes("FROM apps")) { + return [{ + id: "public-app-id", + name: "Public App", + is_public: true, + }]; + } + return []; + }) as any; + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "public.atyg.org", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + assertEquals(res.headers.get("X-Forwarded-App-Id"), "public-app-id"); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (bypass_paths)", async () => { + // Override sql to return app with bypass path + sqlWrapper.sql = (async (strings: any) => { + if (strings[0].includes("FROM apps")) { + return [{ + id: "bypass-app-id", + name: "Bypass App", + bypass_paths: ["/api/public/*"], + }]; + } + return []; + }) as any; + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + "X-Forwarded-Host": "bypass.atyg.org", + "X-Forwarded-Uri": "/api/public/status", + }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); +}); + +Deno.test("Tier 1 & 2: POST /api/guests/sandbox - Creates guest session", async () => { + const { valkey } = await import("./valkey.ts"); + // Mock valkey.setex to prevent connection errors during tests + valkey.setex = async () => "OK" as any; + + const req = new Request("http://localhost/api/guests/sandbox", { + method: "POST", + }); + const res = await app.fetch(req); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.success, true); + assertEquals(true, !!data.sessionId); + assertEquals(true, !!data.guestUuid); const origRpId = Deno.env.get("RP_ID"); Deno.env.delete("RP_ID"); diff --git a/server/main.ts b/server/main.ts index 8c7bc3b..5da67db 100644 --- a/server/main.ts +++ b/server/main.ts @@ -352,13 +352,40 @@ app.post("/api/register/challenge", async (c) => { return c.json({ options, username }); }); +// Generate Ephemeral Guest Sandbox +app.post("/api/guests/sandbox", async (c) => { + const guestUuid = crypto.randomUUID(); + const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))); + const username = `guest-${guestUuid.substring(0, 8)}`; + + await valkey.setex( + sessionId, + 7200, // 2-hour TTL + JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }), + ); + + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 7200, + }); + + return c.json({ success: true, sessionId, guestUuid }); +}); + // Verify registration and create UUID/session app.post("/api/register/verify", async (c) => { try { - const { response, username, inviteCode } = await c.req.json(); + const { response, username, inviteCode, upgrade_session } = await c.req + .json(); - if (!inviteCode) { - return c.json({ error: "inviteCode required" }, 400); + if (!inviteCode && !upgrade_session) { + return c.json({ error: "inviteCode or upgrade_session required" }, 400); } const expectedChallenge = getCookie(c, "expected_registration_challenge"); @@ -485,55 +512,97 @@ app.post("/api/register/verify", async (c) => { prfSalt = encodeBase64Url(saltBytes); } - // Validate invite code at verification time to prevent race conditions - const invite = await sqlWrapper - .sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` - .then((res: any) => res[0]); - if (!invite) { - return c.json( - { error: "Invalid, expired, or fully claimed invite code" }, - 400, - ); - } + if (upgrade_session) { + // Ephemeral Guest Sandbox in-flight promotion + const sessionDataStr = await valkey.get(upgrade_session); + if (!sessionDataStr) { + return c.json({ error: "Invalid or expired guest session" }, 400); + } + const sessionData = JSON.parse(sessionDataStr); + if ( + !sessionData || !sessionData.uuid || + sessionData.account_status !== "guest" + ) { + return c.json({ error: "Invalid guest session state" }, 400); + } - const initialStatus = invite.auto_activate === false ? "pending" : "active"; - const insertRes = await sqlWrapper - .sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`; - user = insertRes[0]; + const guestUuid = sessionData.uuid; - await sqlWrapper.sql` - INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) - VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) - `; + const insertRes = await sqlWrapper + .sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`; + user = insertRes[0]; - await sqlWrapper.sql` - UPDATE invites - SET uses_count = uses_count + 1, - used_at = NOW(), - used_by = ${user.id} - WHERE id = ${invite.id} - `; - - await sqlWrapper.sql` - INSERT INTO invite_redemptions (invite_id, user_id) - VALUES (${invite.id}, ${user.id}) - `; - - if (invite.app_id) { await sqlWrapper.sql` - INSERT INTO grants (user_id, app_id, role) - VALUES (${user.id}, ${invite.app_id}, ${invite.role}) + INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) + VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) `; - } else if (invite.role === "admin") { - const adminApp = await sqlWrapper - .sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'` + + // Promote Valkey session + await valkey.setex( + upgrade_session, + 28800, // Upgrade TTL to 8 hours + JSON.stringify({ uuid: guestUuid, username, account_status: "active" }), + ); + + // Register session in PostgreSQL + const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000); + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, expires_at) + VALUES (${upgrade_session}, ${user.id}, ${expiresAt}) + `; + } else { + // Standard Registration Flow + const invite = await sqlWrapper + .sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` .then((res: any) => res[0]); - if (adminApp) { + if (!invite) { + return c.json( + { error: "Invalid, expired, or fully claimed invite code" }, + 400, + ); + } + + const initialStatus = invite.auto_activate === false + ? "pending" + : "active"; + const insertRes = await sqlWrapper + .sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`; + user = insertRes[0]; + + await sqlWrapper.sql` + INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) + VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) + `; + + await sqlWrapper.sql` + UPDATE invites + SET uses_count = uses_count + 1, + used_at = NOW(), + used_by = ${user.id} + WHERE id = ${invite.id} + `; + + await sqlWrapper.sql` + INSERT INTO invite_redemptions (invite_id, user_id) + VALUES (${invite.id}, ${user.id}) + `; + + if (invite.app_id) { await sqlWrapper.sql` INSERT INTO grants (user_id, app_id, role) - VALUES (${user.id}, ${adminApp.id}, 'admin') - ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin' + VALUES (${user.id}, ${invite.app_id}, ${invite.role}) `; + } else if (invite.role === "admin") { + const adminApp = await sqlWrapper + .sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'` + .then((res: any) => res[0]); + if (adminApp) { + await sqlWrapper.sql` + INSERT INTO grants (user_id, app_id, role) + VALUES (${user.id}, ${adminApp.id}, 'admin') + ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin' + `; + } } } @@ -1022,7 +1091,12 @@ app.post("/api/admin/users/:id/status", async (c) => { // Traefik ForwardAuth Edge Proxy Route (Tier 2) // --------------------------------------------------------- -import { getAppByHost, getUserGrant } from "./auth-session.ts"; +import { + getAppByHost, + getUserGrant, + isIpAllowed, + isPathBypassed, +} from "./auth-session.ts"; import { computeJwkThumbprint, verifyHttpSignature, @@ -1037,8 +1111,34 @@ app.get("/api/forward-auth", async (c) => { // 1. Resolve Target App (Valkey -> DB) const appRecord = await getAppByHost(host); if (!appRecord) { - // Default-Deny if app is not registered - return c.text("Forbidden: Application not registered", 403); + const accept = c.req.header("Accept") || ""; + // If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view + if (accept.includes("text/html")) { + const loginDomain = rpID || "auth.atyg.org"; + return c.redirect( + `https://${loginDomain}/errors/unregistered?host=${ + encodeURIComponent(host) + }`, + 302, + ); + } + // Default-Deny if app is not registered (API requests) + return c.json({ error: "Application not registered" }, 403); + } + + // 1.5 Dynamic Bypass Check + const uri = c.req.header("X-Forwarded-Uri") || "/"; + const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1"; + const requestPath = new URL(uri, `http://${host}`).pathname; + + if ( + appRecord.is_public === true || + isPathBypassed(requestPath, appRecord.bypass_paths) || + isIpAllowed(clientIp, appRecord.allowed_cidrs) + ) { + // Append standard headers even on bypass for downstream context if needed + c.header("X-Forwarded-App-Id", appRecord.id); + return c.text("OK", 200); } // 2. Validate Session OR HTTP Signature @@ -1152,17 +1252,27 @@ app.post("/api/admin/apps", async (c) => { return c.json({ error: "Forbidden" }, 403); } - const { name, spiffeId, description } = await c.req.json(); + const { + name, + spiffeId, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + } = await c.req.json(); if (!name || !spiffeId) { return c.json({ error: "Name and SPIFFE ID are required" }, 400); } try { const newApp = await sqlWrapper.sql` - INSERT INTO apps (name, spiffe_id, description) + INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs) VALUES (${name.trim()}, ${spiffeId.trim()}, ${ description?.trim() || null - }) + }, ${domain?.trim() || null}, ${is_public || false}, ${ + bypass_paths || [] + }, ${allowed_cidrs || []}) RETURNING id, name, spiffe_id, description, created_at `.then((res: any) => res[0]); diff --git a/tasks/new/2026-0824.01.jul.feat.auth-api.traefik-ingress-control-2105.md b/tasks/complete/2026-0824.01.jul.feat.auth-api.traefik-ingress-control-2105.md similarity index 100% rename from tasks/new/2026-0824.01.jul.feat.auth-api.traefik-ingress-control-2105.md rename to tasks/complete/2026-0824.01.jul.feat.auth-api.traefik-ingress-control-2105.md diff --git a/ui/components/AdminAppsPage.tsx b/ui/components/AdminAppsPage.tsx index e38235f..7ab2452 100644 --- a/ui/components/AdminAppsPage.tsx +++ b/ui/components/AdminAppsPage.tsx @@ -81,6 +81,58 @@ export const AdminAppsPage = ({ /> +
+ + +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+