Merge branch 'main' into feat-tier-1-ingress-control-217564989939913720

This commit is contained in:
Tyler Gillispie 2026-08-24 15:13:20 -07:00 committed by GitHub
commit 7230a4d7ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 488 additions and 5 deletions

View File

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

View File

@ -32,6 +32,7 @@ operational and integration scenarios**.
│ 9 │ Multi-App RBAC & Scope Provisioning │ Low │ Simple Web UI Form │ │ 9 │ Multi-App RBAC & Scope Provisioning │ Low │ Simple Web UI Form │
│ 10 │ Cryptographic Compliance & Audit Verification │ Zero │ Automated Merkle Logs │ │ 10 │ Cryptographic Compliance & Audit Verification │ Zero │ Automated Merkle Logs │
│ 11 │ Ephemeral Guest Sandboxes & Open Trial Access │ Minimal │ 1-Click / Zero Passkey │ │ 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. - ForwardAuth transparently sets `X-Forwarded-Scopes: guest,trial` downstream.
- Upgrades convert the ephemeral guest ID to a permanent passkey account - Upgrades convert the ephemeral guest ID to a permanent passkey account
seamlessly in memory. 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.

View File

@ -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; return false;
} }

View File

@ -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 { stub } from "jsr:@std/testing/mock";
import { app } from "./main.ts"; import { app } from "./main.ts";
import { sqlWrapper } from "./db.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(data.success, true);
assertEquals(true, !!data.sessionId); assertEquals(true, !!data.sessionId);
assertEquals(true, !!data.guestUuid); 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;
}
});
}); });

View File

@ -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 (
<AuthenticatedLayout
title="Launchpad"
currentPath="/dashboard"
isAdmin={isAdmin}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "2rem",
}}
>
<h2>Application Launchpad</h2>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1.5rem",
}}
>
{apps.length === 0
? (
<div style={{ color: "#6c757d", gridColumn: "1 / -1" }}>
No authorized applications found.
</div>
)
: (
apps.map((app) => (
<div
class="card"
style={{
display: "flex",
flexDirection: "column",
height: "100%",
marginBottom: "0",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "0.5rem",
}}
>
<h3 style={{ margin: 0, color: "#212529" }}>{app.name}</h3>
<span
class={`badge ${
app.role === "Admin" || app.role === "Global Admin"
? "badge-success"
: "badge-primary"
}`}
style={{
backgroundColor:
app.role === "Admin" || app.role === "Global Admin"
? "#28a745"
: "#007bff",
}}
>
{app.role}
</span>
</div>
<p style={{ color: "#6c757d", flex: 1, fontSize: "0.9rem" }}>
{app.description || "No description provided."}
</p>
<div style={{ marginTop: "1rem", textAlign: "right" }}>
<a
href={`https://${app.domain}`}
class="btn-primary"
style={{ textDecoration: "none", display: "inline-block" }}
>
Launch
</a>
</div>
</div>
))
)}
</div>
</AuthenticatedLayout>
);
};

View File

@ -10,7 +10,7 @@ export const AuthenticatedLayout = ({
isAdmin?: boolean; isAdmin?: boolean;
}) => { }) => {
const navItems = [ const navItems = [
{ label: "Dashboard", href: "/dashboard" }, { label: "Launchpad", href: "/dashboard" },
{ label: "Sessions", href: "/dashboard/sessions" }, { label: "Sessions", href: "/dashboard/sessions" },
{ label: "Passkeys", href: "/dashboard/passkeys" }, { label: "Passkeys", href: "/dashboard/passkeys" },
...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []), ...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []),
@ -168,7 +168,8 @@ export const AuthenticatedLayout = ({
{navItems.map((item) => { {navItems.map((item) => {
const isActive = item.href === "/dashboard" const isActive = item.href === "/dashboard"
? currentPath === "/dashboard" ? currentPath === "/dashboard"
: currentPath.startsWith(item.href); : currentPath.startsWith(item.href) &&
item.href !== "/dashboard";
return ( return (
<a <a

View File

@ -7,7 +7,9 @@ import {
getAuthenticatedUser, getAuthenticatedUser,
getCookieDomain, getCookieDomain,
isGlobalAdmin, isGlobalAdmin,
isSafeRedirectUrl,
} from "../server/auth-session.ts"; } from "../server/auth-session.ts";
import { auditWrapper } from "../server/audit.ts";
import { LoginPage } from "./components/LoginPage.tsx"; import { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx";
import { SessionsPage } from "./components/SessionsPage.tsx"; import { SessionsPage } from "./components/SessionsPage.tsx";
@ -22,6 +24,7 @@ import { AdminAppsPage } from "./components/AdminAppsPage.tsx";
import { AdminRolesPage } from "./components/AdminRolesPage.tsx"; import { AdminRolesPage } from "./components/AdminRolesPage.tsx";
import { AdminInvitesPage } from "./components/AdminInvitesPage.tsx"; import { AdminInvitesPage } from "./components/AdminInvitesPage.tsx";
import { UnregisteredAppPage } from "./components/UnregisteredAppPage.tsx"; import { UnregisteredAppPage } from "./components/UnregisteredAppPage.tsx";
import { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
const uiApp: Hono = new Hono(); const uiApp: Hono = new Hono();
@ -32,8 +35,19 @@ uiApp.get("/", (c) => {
uiApp.get("/logout", async (c) => { uiApp.get("/logout", async (c) => {
const sessionId = getCookie(c, "session_id"); 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) { if (sessionId) {
try { 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 valkey.del(sessionId);
await sql`DELETE FROM sessions WHERE id = ${sessionId}`; await sql`DELETE FROM sessions WHERE id = ${sessionId}`;
} catch (_e) { } 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 rpID = Deno.env.get("RP_ID") || "";
const cookieDomain = getCookieDomain(rpID); const cookieDomain = getCookieDomain(rpID);
@ -60,6 +96,9 @@ uiApp.get("/logout", async (c) => {
sameSite: "Lax", sameSite: "Lax",
}); });
if (safeRedirect) {
return c.redirect(`/login?redirect=${encodeURIComponent(safeRedirect)}`);
}
return c.redirect("/login"); return c.redirect("/login");
}); });
@ -81,8 +120,33 @@ uiApp.get("/register", (c) => {
return c.html(RegisterPage({ initialCode })); return c.html(RegisterPage({ initialCode }));
}); });
uiApp.get("/dashboard", (c) => { uiApp.get("/dashboard", async (c) => {
return c.redirect("/dashboard/sessions"); 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) => { uiApp.get("/dashboard/sessions", async (c) => {