From e81969d5a472fec5c019eea876e1c4c7ce26d020 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Sun, 23 Aug 2026 19:33:35 -0700 Subject: [PATCH] feat(sdk): inject scopes into context, add requireScope guard, add timeoutMs support, and document Traefik dual-router pattern --- COMPOSE_CONVENTIONS.md | 28 ++++++++++++ sdk/hono.test.ts | 101 ++++++++++++++++++++++++++++++++--------- sdk/hono.ts | 36 +++++++++++++-- sdk/mod.test.ts | 1 + sdk/mod.ts | 18 ++++++-- 5 files changed, 156 insertions(+), 28 deletions(-) diff --git a/COMPOSE_CONVENTIONS.md b/COMPOSE_CONVENTIONS.md index cf47203..dfb978c 100644 --- a/COMPOSE_CONVENTIONS.md +++ b/COMPOSE_CONVENTIONS.md @@ -145,6 +145,34 @@ custom application images from upstream cached dependencies: - "traefik.http.routers..tls=true" - "traefik.http.services..loadbalancer.server.port=" ``` +- **Traefik Dual-Router Pattern (Public Ingress Bypass vs. Authenticated + ForwardAuth):** + - When an application exposes both public routes (e.g. webhooks, public + assets, CLI scripts, `/health`) and protected user/admin routes, use + **priority-based dual routers**: + ```yaml + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-net" + + # Router 1: Public Bypass (Priority 100 - Zero Auth Middleware) + - "traefik.http.routers.-public.rule=Host(`${SYSTEM_DOMAIN}`) && (PathPrefix(`/public`) || Path(`/health`))" + - "traefik.http.routers.-public.entrypoints=websecure" + - "traefik.http.routers.-public.tls=true" + - "traefik.http.routers.-public.priority=100" + - "traefik.http.routers.-public.service=-svc" + + # Router 2: Authenticated Web / API (Priority 10 - Tier 2 ForwardAuth) + - "traefik.http.routers.-secure.rule=Host(`${SYSTEM_DOMAIN}`)" + - "traefik.http.routers.-secure.entrypoints=websecure" + - "traefik.http.routers.-secure.tls=true" + - "traefik.http.routers.-secure.priority=10" + - "traefik.http.routers.-secure.middlewares=auth-forward@docker" + - "traefik.http.routers.-secure.service=-svc" + + # Service Definition + - "traefik.http.services.-svc.loadbalancer.server.port=" + ``` --- diff --git a/sdk/hono.test.ts b/sdk/hono.test.ts index 500c505..fa60b21 100644 --- a/sdk/hono.test.ts +++ b/sdk/hono.test.ts @@ -1,24 +1,33 @@ import { Hono } from "jsr:@hono/hono@4"; import { assertEquals } from "jsr:@std/assert"; -import { createAuthMiddleware } from "./hono.ts"; +import { createAuthMiddleware, requireScope } from "./hono.ts"; import { stub } from "jsr:@std/testing/mock"; import { AuthSdk } from "./mod.ts"; type Variables = { userId: string; + scopes: string[]; }; -Deno.test("AuthMiddleware - Authorized request sets userId and calls next", async () => { +Deno.test("AuthMiddleware - Authorized request sets userId and scopes", async () => { const sdk = new AuthSdk({ authApiUrl: "http://localhost" }); - const requireAuthStub = stub( + const validateSessionStub = stub( sdk, - "requireAuth", - () => Promise.resolve("test-user-id"), + "validateSession", + () => + Promise.resolve({ + valid: true, + uuid: "test-user-id", + scopes: ["commander", "viewer"], + }), ); const app = new Hono<{ Variables: Variables }>(); app.use("*", createAuthMiddleware(sdk)); - app.get("/", (c) => c.text(`User: ${c.get("userId")}`)); + app.get( + "/", + (c) => c.json({ userId: c.get("userId"), scopes: c.get("scopes") }), + ); const req = new Request("http://localhost/", { headers: { @@ -28,19 +37,17 @@ Deno.test("AuthMiddleware - Authorized request sets userId and calls next", asyn const res = await app.request(req); assertEquals(res.status, 200); - const text = await res.text(); - assertEquals(text, "User: test-user-id"); + const json = await res.json(); + assertEquals(json, { + userId: "test-user-id", + scopes: ["commander", "viewer"], + }); - requireAuthStub.restore(); + validateSessionStub.restore(); }); Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => { const sdk = new AuthSdk({ authApiUrl: "http://localhost" }); - const requireAuthStub = stub( - sdk, - "requireAuth", - () => Promise.reject(new Error("Unauthorized")), - ); const app = new Hono<{ Variables: Variables }>(); app.use("*", createAuthMiddleware(sdk)); @@ -51,17 +58,15 @@ Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => { const res = await app.request(req); assertEquals(res.status, 401); const json = await res.json(); - assertEquals(json, { error: "Unauthorized" }); - - requireAuthStub.restore(); + assertEquals(json, { error: "Unauthorized: Missing session token" }); }); Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => { const sdk = new AuthSdk({ authApiUrl: "http://localhost" }); - const requireAuthStub = stub( + const validateSessionStub = stub( sdk, - "requireAuth", - () => Promise.reject(new Error("Unauthorized")), + "validateSession", + () => Promise.resolve({ valid: false, error: "Invalid token" }), ); const app = new Hono<{ Variables: Variables }>(); @@ -77,7 +82,59 @@ Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => { const res = await app.request(req); assertEquals(res.status, 401); const json = await res.json(); - assertEquals(json, { error: "Unauthorized" }); + assertEquals(json, { error: "Invalid token" }); - requireAuthStub.restore(); + validateSessionStub.restore(); +}); + +Deno.test("requireScope - allows user when scope is present", async () => { + const app = new Hono<{ Variables: Variables }>(); + app.use("*", (c, next) => { + c.set("userId", "test-user"); + c.set("scopes", ["commander", "viewer"]); + return next(); + }); + app.get("/command", requireScope("commander"), (c) => c.text("Command OK")); + + const req = new Request("http://localhost/command"); + const res = await app.request(req); + assertEquals(res.status, 200); + assertEquals(await res.text(), "Command OK"); +}); + +Deno.test("requireScope - allows user when global admin scope is present", async () => { + const app = new Hono<{ Variables: Variables }>(); + app.use("*", (c, next) => { + c.set("userId", "admin-user"); + c.set("scopes", ["admin"]); + return next(); + }); + app.get( + "/restricted", + requireScope("special-scope"), + (c) => c.text("Admin Override OK"), + ); + + const req = new Request("http://localhost/restricted"); + const res = await app.request(req); + assertEquals(res.status, 200); + assertEquals(await res.text(), "Admin Override OK"); +}); + +Deno.test("requireScope - rejects user when scope is missing", async () => { + const app = new Hono<{ Variables: Variables }>(); + app.use("*", (c, next) => { + c.set("userId", "test-user"); + c.set("scopes", ["viewer"]); + return next(); + }); + app.get("/command", requireScope("commander"), (c) => c.text("Command OK")); + + const req = new Request("http://localhost/command"); + const res = await app.request(req); + assertEquals(res.status, 403); + const json = await res.json(); + assertEquals(json, { + error: "Forbidden: Required scope 'commander' missing.", + }); }); diff --git a/sdk/hono.ts b/sdk/hono.ts index ae7cd70..b7a830c 100644 --- a/sdk/hono.ts +++ b/sdk/hono.ts @@ -2,6 +2,11 @@ import type { Context, Next } from "jsr:@hono/hono@4"; import { getCookie } from "jsr:@hono/hono@4/cookie"; import type { AuthSdk } from "./mod.ts"; +/** + * Universal Hono authentication middleware for Auth-Yes. + * Extracts session tokens from cookies or Authorization Bearer headers, + * validates against Auth-Yes, and injects 'userId' and 'scopes' into context. + */ export function createAuthMiddleware(sdk: AuthSdk) { return async (c: Context, next: Next) => { let token = getCookie(c, "session_id"); @@ -14,15 +19,40 @@ export function createAuthMiddleware(sdk: AuthSdk) { } if (!token) { - return c.json({ error: "Unauthorized" }, 401); + return c.json({ error: "Unauthorized: Missing session token" }, 401); } try { - const userId = await sdk.requireAuth(token); - c.set("userId", userId); + const session = await sdk.validateSession(token); + if (!session.valid || !session.uuid) { + return c.json( + { error: session.error || "Unauthorized: Invalid session" }, + 401, + ); + } + + c.set("userId", session.uuid); + c.set("scopes", session.scopes || []); await next(); } catch (_e) { return c.json({ error: "Unauthorized" }, 401); } }; } + +/** + * RBAC Scope Guard Middleware. + * Ensures the authenticated user possesses the required application scope (or global 'admin'). + */ +export function requireScope(requiredScope: string) { + return async (c: Context, next: Next) => { + const scopes: string[] = c.get("scopes") || []; + if (!scopes.includes(requiredScope) && !scopes.includes("admin")) { + return c.json( + { error: `Forbidden: Required scope '${requiredScope}' missing.` }, + 403, + ); + } + await next(); + }; +} diff --git a/sdk/mod.test.ts b/sdk/mod.test.ts index 16df2a4..d424403 100644 --- a/sdk/mod.test.ts +++ b/sdk/mod.test.ts @@ -4,6 +4,7 @@ import { createAuthSdk } from "./mod.ts"; Deno.test("AuthSdk - initializes with config", () => { const sdk = createAuthSdk({ authApiUrl: "http://localhost:8000", + timeoutMs: 3000, }); assertEquals(typeof sdk.validateSession, "function"); assertEquals(typeof sdk.requireAuth, "function"); diff --git a/sdk/mod.ts b/sdk/mod.ts index 4b66aca..a2d75c2 100644 --- a/sdk/mod.ts +++ b/sdk/mod.ts @@ -37,6 +37,10 @@ export interface AuthSdkConfig { * The mTLS CA certificate. */ tlsCa?: string; + /** + * Network timeout for ConnectRPC calls in milliseconds (default: 5000ms). + */ + timeoutMs?: number; } /** @@ -142,7 +146,10 @@ export class AuthSdk { * @param token The opaque session token (e.g., extracted from a cookie). * @returns The validated session data containing the UUID and scopes. */ - async validateSession(token: string): Promise { + async validateSession( + token: string, + options?: { signal?: AbortSignal }, + ): Promise { // Check L1 cache first const cachedSession = this.l1Cache.get(token); if (cachedSession) { @@ -150,12 +157,17 @@ export class AuthSdk { } try { - const response = await this.grpcClient.validateSession({ token }); + const timeout = this.config.timeoutMs || 5000; + const signal = options?.signal || AbortSignal.timeout(timeout); + const response = await this.grpcClient.validateSession( + { token }, + { signal }, + ); const sessionData: SessionData = { valid: response.valid, uuid: response.uuid, - scopes: response.scopes, + scopes: response.scopes || [], error: response.error, };