diff --git a/docs/HYBRID_INGRESS_PLAYBOOK.md b/docs/HYBRID_INGRESS_PLAYBOOK.md new file mode 100644 index 0000000..f7fc2a3 --- /dev/null +++ b/docs/HYBRID_INGRESS_PLAYBOOK.md @@ -0,0 +1,96 @@ +# Hybrid Ingress Routing Playbook + +This playbook outlines the recommended architecture for deploying consumer +applications that require a combination of public-facing endpoints (like splash +pages or APIs) and private, authenticated endpoints (like control panels or user +dashboards), while integrating with Auth-Yes. + +## Context + +Many applications are not entirely public or entirely private. They utilize a +hybrid approach where certain paths are accessible to anyone, while others are +strictly protected. To achieve Zero-Trust security and streamline +authentication, we use a dual-routing pattern leveraging Traefik and +application-level SSR hydration. + +## The Dual-Routing Pattern + +The dual-routing pattern separates responsibilities between the edge proxy +(Traefik) and the application itself. + +### 1. Edge Proxy Authentication (Traefik ForwardAuth) + +For paths that must be strictly private and require a valid user session (or +machine identity), we use Traefik's `ForwardAuth` middleware. This delegates the +authentication decision to the Auth-Yes edge node. + +**Protected Paths:** + +- `/control-panel/*` +- `/dashboard/*` +- `/ws/*` (WebSockets) +- `/api/private/*` + +**How it works:** + +1. A request arrives at Traefik for a protected path (e.g., + `/control-panel/settings`). +2. Traefik intercepts the request and sends a sub-request to the Auth-Yes + `ForwardAuth` endpoint (`/api/forward-auth`). +3. Auth-Yes validates the `session_id` cookie or RFC 9421 HTTP Message + Signature. +4. If valid, Auth-Yes returns HTTP 200 OK, injecting context headers like + `X-Forwarded-User` and `X-Forwarded-Scopes`. +5. Traefik allows the original request to proceed to the application. +6. If invalid, Auth-Yes intercepts with a redirect (for browsers) or 403 + Forbidden (for APIs/daemons). + +**Example Traefik Configuration (Labels):** + +```yaml +labels: + - "traefik.http.routers.myapp-private.rule=Host(`myapp.atyg.org`) && (PathPrefix(`/control-panel`) || PathPrefix(`/api/private`))" + - "traefik.http.routers.myapp-private.middlewares=auth-yes-forwardauth" + - "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.address=http://auth-api:8000/api/forward-auth" + - "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.authResponseHeaders=X-Forwarded-User,X-Forwarded-Scopes" +``` + +### 2. Application-Level Authentication (SSR Hydration) + +For paths that are public or require custom application logic to handle +unauthenticated users gracefully, the application itself handles the +authentication state via SDKs or custom logic. + +**Public/Hybrid Paths:** + +- `/` (Splash page, landing page) +- `/about` +- `/api/public/*` +- `/.well-known/*` + +**How it works:** + +1. A request arrives at Traefik for a public path (e.g., `/`). +2. Traefik routes the request directly to the application (no `ForwardAuth` + middleware applied). +3. The application receives the request. It can check for the presence of a + `session_id` cookie if it wants to render personalized content (e.g., + replacing "Login" with "Go to Control Panel"). +4. If no session exists, it renders the public splash page. + +**Example Traefik Configuration (Labels):** + +```yaml +labels: + - "traefik.http.routers.myapp-public.rule=Host(`myapp.atyg.org`)" + # No ForwardAuth middleware here +``` + +## Summary + +By combining Traefik `ForwardAuth` for strict edge-level protection of critical +paths with application-level handling for public paths, we achieve a robust, +flexible, and secure ingress architecture. This ensures that sensitive routes +are never accidentally exposed, while public routes remain performant and +accessible. diff --git a/docs/USE_CASES_AND_EFFORT.md b/docs/USE_CASES_AND_EFFORT.md index 579f5d2..a79d069 100644 --- a/docs/USE_CASES_AND_EFFORT.md +++ b/docs/USE_CASES_AND_EFFORT.md @@ -32,6 +32,7 @@ operational and integration scenarios**. │ 9 │ Multi-App RBAC & Scope Provisioning │ Low │ Simple Web UI Form │ │ 10 │ Cryptographic Compliance & Audit Verification │ Zero │ Automated Merkle Logs │ │ 11 │ Ephemeral Guest Sandboxes & Open Trial Access │ Minimal │ 1-Click / Zero Passkey │ +│ 12 │ Hybrid Cloud Edge & Remote Tunnel Ingress │ Low │ Zero-Touch Tunnel / DoH│ └────┴─────────────────────────────────────────────────┴──────────────────┴────────────────────────┘ ``` @@ -285,3 +286,31 @@ operational and integration scenarios**. - ForwardAuth transparently sets `X-Forwarded-Scopes: guest,trial` downstream. - Upgrades convert the ephemeral guest ID to a permanent passkey account seamlessly in memory. + +--- + +### Use Case 12: Hybrid Cloud Edge & Remote Tunnel Ingress (Cloudflare Tunnel, Split-Horizon DNS & WAN Resiliency) + +- **Difficulty Level:** **Low (Zero-Touch Tunnel Ingress / Transparent + Split-Horizon)** +- **What you actually have to do:** + 1. Map public hostnames in Cloudflare Zero Trust Tunnels (`cloudflared`) + pointing `auth.atyg.org` and downstream subdomains (e.g. + `ed-droid.atyg.org`) to internal Traefik (`http://192.168.1.10:80` or + `https://192.168.1.10:443` with "No TLS Verify"). + 2. Local LAN devices resolve directly to `192.168.1.10` via local router/DNS, + while remote mobile clients resolve via Cloudflare Anycast edge proxies. + 3. Auth-Yes handles ForwardAuth 302 redirects and session cookie scoping + transparently across both local LAN and remote WAN ingress vectors. +- **Audit & Architectural Resilience Verification Requirements:** + - **Cookie Scope Parity:** Ensure `Set-Cookie` with wildcard domain + `.atyg.org` is preserved across reverse proxy tunnels without origin + truncation. + - **Open-Redirect & Deep-Link Preservation:** Verify + `https://auth.atyg.org/login?redirect=...` deep-links survive multi-hop + proxies and DoH mobile resolvers. + - **Negative DNS Caching & Mobile DoH Isolation:** Validate system behavior + when client mobile OS resolvers transition between Wi-Fi split-horizon DNS + and cellular DNS over HTTPS (DoH). + - **Latency SLA:** Verify ForwardAuth edge lookup latency remains $<50\mu s$ + on internal LAN and $<10$ms over Cloudflare Tunnel edge. diff --git a/server/auth-session.ts b/server/auth-session.ts index 804c2cb..fb3bef5 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -299,5 +299,36 @@ export function isIpAllowed( } } + * Validates a given URL to ensure it is safe to redirect to. + * Allows relative paths, localhost, and *.atyg.org (or custom RP_ID) + */ +export function isSafeRedirectUrl( + rawUrl: string, + customDomain?: string, +): boolean { + if (!rawUrl) return false; + // 1. Relative paths within the same origin are safe + if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) { + return true; + } + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname; + const root = customDomain || Deno.env.get("RP_ID") || "atyg.org"; + const cleanRoot = root.replace(/^\./, ""); + + // 2. Allow localhost, exact root match, or subdomains (*.atyg.org) + if ( + host === "localhost" || + host === "atyg.org" || + host.endsWith(".atyg.org") || + host === cleanRoot || + host.endsWith(`.${cleanRoot}`) + ) { + return true; + } + } catch (_e) { + return false; + } return false; } diff --git a/server/main.test.ts b/server/main.test.ts index 06ac570..9d5afb7 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertExists } from "jsr:@std/assert"; +import { assert, assertEquals, assertExists } from "jsr:@std/assert"; import { stub } from "jsr:@std/testing/mock"; import { app } from "./main.ts"; import { sqlWrapper } from "./db.ts"; @@ -520,4 +520,164 @@ Deno.test("Tier 1 & 2: POST /api/guests/sandbox - Creates guest session", async assertEquals(data.success, true); assertEquals(true, !!data.sessionId); assertEquals(true, !!data.guestUuid); + const origRpId = Deno.env.get("RP_ID"); + Deno.env.delete("RP_ID"); + + try { + 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); + assertEquals(getCookieDomain(""), undefined); + } finally { + if (origRpId !== undefined) { + Deno.env.set("RP_ID", origRpId); + } + } +}); + +Deno.test("Logout Return-Path Validation", async (t) => { + await t.step("GET /logout preserves valid redirect", async () => { + const res = await app.request( + "/logout?redirect=https://ed-droid.atyg.org/", + { + method: "GET", + }, + ); + assertEquals(res.status, 302); + const location = res.headers.get("location"); + assertExists(location); + assertEquals( + location, + "/login?redirect=https%3A%2F%2Fed-droid.atyg.org%2F", + ); + }); + + await t.step("GET /logout intercepts malicious redirect", async () => { + const res = await app.request("/logout?redirect=https://evil.com", { + method: "GET", + }); + assertEquals(res.status, 302); + const location = res.headers.get("location"); + assertExists(location); + // Malicious redirect should be discarded, so it just redirects to /login + assertEquals(location, "/login"); + }); +}); + +Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => { + await t.step( + "GET /dashboard redirects to /login if unauthenticated", + async () => { + const res = await app.request("/dashboard", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/login"); + }, + ); + + await t.step("GET /dashboard renders for admin", async () => { + const mockGet = (key: string) => { + if (key === "valid_session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "admin" }), + ); + } + return Promise.resolve(null); + }; + valkey.get = mockGet as any; + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("g.role = 'admin'")) { + return Promise.resolve([{ id: "grant-id" }]); + } + if ( + query.includes("SELECT id, name, description, domain, 'Admin' as role") + ) { + return Promise.resolve([ + { + id: "app1", + name: "App 1", + description: "Desc", + domain: "app1.com", + role: "Admin", + }, + { + id: "app2", + name: "App 2", + description: "Desc", + domain: "app2.com", + role: "Admin", + }, + ]); + } + return Promise.resolve([]); + }; + + try { + const res = await app.request("/dashboard", { + method: "GET", + headers: { Cookie: "session_id=valid_session" }, + }); + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes("App 1")); + assert(text.includes("App 2")); + assert(text.includes("Admin Console")); // Layout link + } finally { + sqlWrapper.sql = originalSql; + } + }); + + await t.step("GET /dashboard renders for regular user", async () => { + const mockGet = (key: string) => { + if (key === "valid_session") { + return Promise.resolve( + JSON.stringify({ uuid: "user-uuid", username: "user" }), + ); + } + return Promise.resolve(null); + }; + valkey.get = mockGet as any; + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (strings: any, ..._values: any[]) => { + const query = strings.join("?"); + if (query.includes("g.role = 'admin'")) { + return Promise.resolve([]); // Not admin + } + if (query.includes("ORDER BY created_at ASC LIMIT 1")) { + return Promise.resolve([{ id: "different-user" }]); + } + if ( + query.includes("SELECT a.id, a.name, a.description, a.domain, g.role") + ) { + return Promise.resolve([ + { + id: "app1", + name: "App 1", + description: "Desc", + domain: "app1.com", + role: "Viewer", + }, + ]); + } + return Promise.resolve([]); + }; + + try { + const res = await app.request("/dashboard", { + method: "GET", + headers: { Cookie: "session_id=valid_session" }, + }); + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes("App 1")); + assert(!text.includes("App 2")); + assert(!text.includes("Admin Console")); // Layout link should be missing + } finally { + sqlWrapper.sql = originalSql; + } + }); }); diff --git a/tasks/new/2026-0824.02.jul.story.app-launchpad.sso-launchpad-and-logout-2100.md b/tasks/complete/2026-0824.02.jul.story.app-launchpad.sso-launchpad-and-logout-2100.md similarity index 100% rename from tasks/new/2026-0824.02.jul.story.app-launchpad.sso-launchpad-and-logout-2100.md rename to tasks/complete/2026-0824.02.jul.story.app-launchpad.sso-launchpad-and-logout-2100.md diff --git a/ui/components/AppLaunchpadPage.tsx b/ui/components/AppLaunchpadPage.tsx new file mode 100644 index 0000000..871e233 --- /dev/null +++ b/ui/components/AppLaunchpadPage.tsx @@ -0,0 +1,102 @@ +import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx"; + +export interface AppCard { + id: string; + name: string; + description: string; + domain: string; + role: string; +} + +export const AppLaunchpadPage = ({ + apps, + isAdmin, +}: { + apps: AppCard[]; + isAdmin: boolean; +}) => { + return ( + +
+

Application Launchpad

+
+ +
+ {apps.length === 0 + ? ( +
+ No authorized applications found. +
+ ) + : ( + apps.map((app) => ( +
+
+

{app.name}

+ + {app.role} + +
+

+ {app.description || "No description provided."} +

+
+ + Launch + +
+
+ )) + )} +
+
+ ); +}; diff --git a/ui/components/AuthenticatedLayout.tsx b/ui/components/AuthenticatedLayout.tsx index 31c586b..77373a6 100644 --- a/ui/components/AuthenticatedLayout.tsx +++ b/ui/components/AuthenticatedLayout.tsx @@ -10,7 +10,7 @@ export const AuthenticatedLayout = ({ isAdmin?: boolean; }) => { const navItems = [ - { label: "Dashboard", href: "/dashboard" }, + { label: "Launchpad", href: "/dashboard" }, { label: "Sessions", href: "/dashboard/sessions" }, { label: "Passkeys", href: "/dashboard/passkeys" }, ...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []), @@ -168,7 +168,8 @@ export const AuthenticatedLayout = ({ {navItems.map((item) => { const isActive = item.href === "/dashboard" ? currentPath === "/dashboard" - : currentPath.startsWith(item.href); + : currentPath.startsWith(item.href) && + item.href !== "/dashboard"; return ( { uiApp.get("/logout", async (c) => { const sessionId = getCookie(c, "session_id"); + const rawRedirect = c.req.query("redirect"); + let safeRedirect = null; + const userIp = c.req.header("x-forwarded-for") || "127.0.0.1"; + let userId = null; + if (sessionId) { try { + // Get user ID for auditing before we delete the session + const authUser = await getAuthenticatedUser(c); + if (authUser) { + userId = authUser.userId; + } + await valkey.del(sessionId); await sql`DELETE FROM sessions WHERE id = ${sessionId}`; } catch (_e) { @@ -41,6 +55,28 @@ uiApp.get("/logout", async (c) => { } } + if (rawRedirect) { + if (isSafeRedirectUrl(rawRedirect)) { + safeRedirect = rawRedirect; + } else { + auditWrapper.auditLog( + userId, + "open_redirect_intercepted", + "logout_redirect", + { raw_url: rawRedirect }, + userIp, + ); + } + } + + auditWrapper.auditLog( + userId, + "logout_success", + "session", + null, + userIp, + ); + const rpID = Deno.env.get("RP_ID") || ""; const cookieDomain = getCookieDomain(rpID); @@ -60,6 +96,9 @@ uiApp.get("/logout", async (c) => { sameSite: "Lax", }); + if (safeRedirect) { + return c.redirect(`/login?redirect=${encodeURIComponent(safeRedirect)}`); + } return c.redirect("/login"); }); @@ -81,8 +120,33 @@ uiApp.get("/register", (c) => { return c.html(RegisterPage({ initialCode })); }); -uiApp.get("/dashboard", (c) => { - return c.redirect("/dashboard/sessions"); +uiApp.get("/dashboard", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { + return c.redirect("/login"); + } + + const isAdmin = await isGlobalAdmin(auth.userId); + + let apps = []; + if (isAdmin) { + apps = await sql` + SELECT id, name, description, domain, 'Admin' as role + FROM apps + WHERE domain IS NOT NULL + ORDER BY name ASC + ` as any[]; + } else { + apps = await sql` + SELECT a.id, a.name, a.description, a.domain, g.role + FROM apps a + JOIN grants g ON a.id = g.app_id + WHERE g.user_id = ${auth.userId} AND a.domain IS NOT NULL + ORDER BY a.name ASC + ` as any[]; + } + + return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin })); }); uiApp.get("/dashboard/sessions", async (c) => {