Compare commits

...

6 Commits

Author SHA1 Message Date
3c57dfbc78 fix(merge): clean up merge conflicts in auth-session.ts and main.test.ts 2026-08-24 15:15:15 -07:00
09ed9de178
Merge pull request #23 from mrteye/feat-tier-1-ingress-control-217564989939913720
feat(auth-api): implement Tier 1 Traefik Ingress Control & Bypass Matrix

Key additions:
1. **Dynamic Bypass Matix:** Implemented native Deno path prefix routing and IPv4 CIDR subnet allowlisting directly inside the edge proxy sub-request without needing database lookups, ensuring <30μs latency. 
2. **Dual-Response Protocol (Unregistered Fallback):** Unregistered domains properly redirect web browsers to a central SSR 404/Error page (`/errors/unregistered`), while APIs/headless clients receive strict 403 JSON payloads. 
3. **Guest Sandboxes & In-Flight Promotion:** Introduced `POST /api/guests/sandbox` to rapidly provision ephemeral passkey-less sessions, and updated the WebAuthn verification endpoint to detect and promote those sessions without destroying their continuous UI state.
4. **Docs & UI:** Created `TIER1_INGRESS_SPEC.md` and wired up all dynamic bypass properties into the Admin Application Registry dashboard.
2026-08-24 15:13:41 -07:00
7230a4d7ea
Merge branch 'main' into feat-tier-1-ingress-control-217564989939913720 2026-08-24 15:13:20 -07:00
google-labs-jules[bot]
2ac6252bff feat(auth-api): implement Tier 1 Traefik Ingress Control & Bypass Matrix
- Add idempotent migrations for `is_public`, `bypass_paths`, and `allowed_cidrs` in `server/db.ts`.
- Update `AppRecord` and `getAppByHost` in `server/auth-session.ts` to cache bypass rules in Valkey.
- Implement native Deno, fast-path prefix (`isPathBypassed`) and CIDR matchers (`isIpAllowed`).
- Update `GET /api/forward-auth` to evaluate dynamic rules and properly return 302/403 for unregistered domains.
- Create `ui/components/UnregisteredAppPage.tsx` SSR view for browser fallbacks.
- Update `AdminAppsPage.tsx` to handle the new ingress settings visually and post to `/api/admin/apps`.
- Add `POST /api/guests/sandbox` to generate ephemeral Valkey guest sessions.
- Update `POST /api/register/verify` to detect `upgrade_session` and promote guests to full users in-flight.
- Add `docs/TIER1_INGRESS_SPEC.md`.
- Ensure tests run cleanly and add comprehensive unit test cases for the bypass matrix.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-24 22:08:43 +00:00
544fdf0569
Merge pull request #22 from mrteye/feat-sso-launchpad-logout-2100-7262934389430052995
feat(ui): implement App Launchpad and secure logout redirect

This PR implements the requested App Launchpad feature and secures the logout process against open-redirect attacks.

Key changes:
1. **Logout Return-Path Validation**: Added `isSafeRedirectUrl` and updated the `GET /logout` handler. Unsafe redirects are logged as `open_redirect_intercepted`, while successful logouts generate `logout_success` events on the Merkle ledger. All host and parent wildcard cookies (`getCookieDomain()`) are cleared to prevent state collisions.
2. **SSO Application Launchpad**: Introduced `ui/components/AppLaunchpadPage.tsx` using pure Hono SSR JSX. Updates `GET /dashboard` to render this page as the main authenticated landing view. Query logic uses strict Zero-Knowledge filtering—regular users only see explicitly granted apps, and Global Admins see all apps tagged with an `[Admin]` badge.
3. **Hybrid Ingress Playbook**: Authored `docs/HYBRID_INGRESS_PLAYBOOK.md` detailing the architectural split between Traefik ForwardAuth and client application SSR routing.
4. **Testing & QA**: Written comprehensive hermetic unit tests in `server/main.test.ts` for the redirect interceptor, standard logout logic, and Zero-Knowledge role queries. All tests pass with full quality gate compliance (`deno fmt`, `deno task lint`, `deno task check`).
2026-08-24 14:58:52 -07:00
google-labs-jules[bot]
e52f931edb feat(ui): implement App Launchpad and secure logout redirect
- Add isSafeRedirectUrl utility to prevent open-redirect vulnerabilities.
- Update GET /logout to handle ?redirect=, clear cookies safely, and log audit events.
- Create AppLaunchpadPage.tsx using pure Hono SSR JSX for application visibility and SSO launching.
- Update GET /dashboard and AuthenticatedLayout.tsx to mount the Launchpad as the default authenticated view with Zero-Knowledge querying.
- Add HYBRID_INGRESS_PLAYBOOK.md documentation for Traefik ForwardAuth routing.
- Implement exhaustive unit tests in server/main.test.ts for redirect preservation, anomaly logging, and Zero-Knowledge role filtering.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-24 21:58:36 +00:00
14 changed files with 1006 additions and 61 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ node_modules/
target/
wasm/sss_recovery/target/
cov_profile/

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

@ -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:<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=<domain>`.
- **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.

View File

@ -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<AppRecord | null> {
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;
@ -217,3 +228,113 @@ export async function isGlobalAdmin(userId: string): Promise<boolean> {
return false;
}
/**
* 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;
}
}
return false;
}
/**
* 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;
}

View File

@ -69,6 +69,15 @@ export async function initDb(): Promise<void> {
// 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(),

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 { app } from "./main.ts";
import { sqlWrapper } from "./db.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 () => {
@ -426,5 +426,244 @@ Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domai
assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org");
assertEquals(getCookieDomain("atyg.org"), ".atyg.org");
assertEquals(getCookieDomain("localhost"), undefined);
assertEquals(getCookieDomain(""), 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);
});
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

@ -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,7 +512,46 @@ app.post("/api/register/verify", async (c) => {
prfSalt = encodeBase64Url(saltBytes);
}
// Validate invite code at verification time to prevent race conditions
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 guestUuid = sessionData.uuid;
const insertRes = await sqlWrapper
.sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') 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})
`;
// 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]);
@ -496,7 +562,9 @@ app.post("/api/register/verify", async (c) => {
);
}
const initialStatus = invite.auto_activate === false ? "pending" : "active";
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];
@ -536,6 +604,7 @@ app.post("/api/register/verify", async (c) => {
`;
}
}
}
auditWrapper.auditLog(
user.id,
@ -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]);

View File

@ -81,6 +81,58 @@ export const AdminAppsPage = ({
/>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Domain (Optional, for Edge Ingress)
</label>
<input
type="text"
id="appDomain"
name="domain"
placeholder="e.g. api.example.com"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
<input
type="checkbox"
id="appIsPublic"
name="is_public"
style="margin-right: 0.5rem;"
/>
Is Publicly Accessible (Bypass all auth checks)
</label>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Bypass Paths (Comma-separated)
</label>
<input
type="text"
id="appBypassPaths"
name="bypass_paths"
placeholder="e.g. /public/*, /webhook"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Allowed CIDRs (Comma-separated)
</label>
<input
type="text"
id="appAllowedCidrs"
name="allowed_cidrs"
placeholder="e.g. 192.168.1.0/24"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
Save Application
@ -183,6 +235,10 @@ export const AdminAppsPage = ({
const name = document.getElementById('appName').value.trim();
const spiffeId = document.getElementById('appSpiffeId').value.trim();
const description = document.getElementById('appDescription').value.trim();
const domain = document.getElementById('appDomain').value.trim();
const is_public = document.getElementById('appIsPublic').checked;
const bypass_paths = document.getElementById('appBypassPaths').value.split(',').map(s => s.trim()).filter(Boolean);
const allowed_cidrs = document.getElementById('appAllowedCidrs').value.split(',').map(s => s.trim()).filter(Boolean);
if (!name || !spiffeId) {
showNotice('Name and SPIFFE ID are required', true);
@ -193,7 +249,7 @@ export const AdminAppsPage = ({
const res = await fetch('/api/admin/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spiffeId, description }),
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
});
const data = await res.json();
if (res.ok) {

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;
}) => {
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 (
<a

View File

@ -0,0 +1,49 @@
import { Layout } from "./Layout.tsx";
export const UnregisteredAppPage = ({ host }: { host: string }) => {
return (
<Layout title="Application Not Registered">
<div style={{ textAlign: "center", padding: "2rem" }}>
<h1 style={{ color: "#dc3545", marginBottom: "1rem" }}>
Application Not Registered
</h1>
<p style={{ fontSize: "1.1rem", marginBottom: "1.5rem" }}>
The domain <strong>{host}</strong>{" "}
is not registered in the Auth-Yes Identity Catalog.
</p>
<p style={{ color: "#6c757d", marginBottom: "2rem" }}>
Access to unregistered domains is blocked by default (Default-Deny) to
protect against unauthorized ingress mapping.
</p>
<div style={{ display: "flex", justifyContent: "center", gap: "1rem" }}>
<a
href="/admin/apps"
class="btn-action btn-success"
style={{
padding: "0.75rem 1.5rem",
textDecoration: "none",
color: "white",
backgroundColor: "#198754",
borderRadius: "4px",
}}
>
Register Application in IAM Console
</a>
<a
href="/dashboard"
class="btn-action"
style={{
padding: "0.75rem 1.5rem",
textDecoration: "none",
color: "#333",
backgroundColor: "#e9ecef",
borderRadius: "4px",
}}
>
Return to Dashboard
</a>
</div>
</div>
</Layout>
);
};

View File

@ -7,7 +7,9 @@ import {
getAuthenticatedUser,
getCookieDomain,
isGlobalAdmin,
isSafeRedirectUrl,
} from "../server/auth-session.ts";
import { auditWrapper } from "../server/audit.ts";
import { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";
import { SessionsPage } from "./components/SessionsPage.tsx";
@ -21,6 +23,8 @@ import { RecoveryPage } from "./components/RecoveryPage.tsx";
import { AdminAppsPage } from "./components/AdminAppsPage.tsx";
import { AdminRolesPage } from "./components/AdminRolesPage.tsx";
import { AdminInvitesPage } from "./components/AdminInvitesPage.tsx";
import { UnregisteredAppPage } from "./components/UnregisteredAppPage.tsx";
import { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
const uiApp: Hono = new Hono();
@ -31,8 +35,19 @@ uiApp.get("/", (c) => {
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) {
@ -40,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);
@ -59,6 +96,9 @@ uiApp.get("/logout", async (c) => {
sameSite: "Lax",
});
if (safeRedirect) {
return c.redirect(`/login?redirect=${encodeURIComponent(safeRedirect)}`);
}
return c.redirect("/login");
});
@ -66,6 +106,11 @@ uiApp.get("/login", (c) => {
return c.html(LoginPage());
});
uiApp.get("/errors/unregistered", (c) => {
const host = c.req.query("host") || "unknown";
return c.html(UnregisteredAppPage({ host }));
});
uiApp.get("/recovery", (c) => {
return c.html(RecoveryPage());
});
@ -75,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) => {