feat(sdk): inject scopes into context, add requireScope guard, add timeoutMs support, and document Traefik dual-router pattern
This commit is contained in:
parent
a85223f149
commit
e81969d5a4
@ -145,6 +145,34 @@ custom application images from upstream cached dependencies:
|
|||||||
- "traefik.http.routers.<app>.tls=true"
|
- "traefik.http.routers.<app>.tls=true"
|
||||||
- "traefik.http.services.<app>.loadbalancer.server.port=<port>"
|
- "traefik.http.services.<app>.loadbalancer.server.port=<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.<app>-public.rule=Host(`${SYSTEM_DOMAIN}`) && (PathPrefix(`/public`) || Path(`/health`))"
|
||||||
|
- "traefik.http.routers.<app>-public.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.<app>-public.tls=true"
|
||||||
|
- "traefik.http.routers.<app>-public.priority=100"
|
||||||
|
- "traefik.http.routers.<app>-public.service=<app>-svc"
|
||||||
|
|
||||||
|
# Router 2: Authenticated Web / API (Priority 10 - Tier 2 ForwardAuth)
|
||||||
|
- "traefik.http.routers.<app>-secure.rule=Host(`${SYSTEM_DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.<app>-secure.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.<app>-secure.tls=true"
|
||||||
|
- "traefik.http.routers.<app>-secure.priority=10"
|
||||||
|
- "traefik.http.routers.<app>-secure.middlewares=auth-forward@docker"
|
||||||
|
- "traefik.http.routers.<app>-secure.service=<app>-svc"
|
||||||
|
|
||||||
|
# Service Definition
|
||||||
|
- "traefik.http.services.<app>-svc.loadbalancer.server.port=<port>"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
101
sdk/hono.test.ts
101
sdk/hono.test.ts
@ -1,24 +1,33 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { assertEquals } from "jsr:@std/assert";
|
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 { stub } from "jsr:@std/testing/mock";
|
||||||
import { AuthSdk } from "./mod.ts";
|
import { AuthSdk } from "./mod.ts";
|
||||||
|
|
||||||
type Variables = {
|
type Variables = {
|
||||||
userId: string;
|
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 sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
||||||
const requireAuthStub = stub(
|
const validateSessionStub = stub(
|
||||||
sdk,
|
sdk,
|
||||||
"requireAuth",
|
"validateSession",
|
||||||
() => Promise.resolve("test-user-id"),
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
valid: true,
|
||||||
|
uuid: "test-user-id",
|
||||||
|
scopes: ["commander", "viewer"],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const app = new Hono<{ Variables: Variables }>();
|
const app = new Hono<{ Variables: Variables }>();
|
||||||
app.use("*", createAuthMiddleware(sdk));
|
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/", {
|
const req = new Request("http://localhost/", {
|
||||||
headers: {
|
headers: {
|
||||||
@ -28,19 +37,17 @@ Deno.test("AuthMiddleware - Authorized request sets userId and calls next", asyn
|
|||||||
|
|
||||||
const res = await app.request(req);
|
const res = await app.request(req);
|
||||||
assertEquals(res.status, 200);
|
assertEquals(res.status, 200);
|
||||||
const text = await res.text();
|
const json = await res.json();
|
||||||
assertEquals(text, "User: test-user-id");
|
assertEquals(json, {
|
||||||
|
userId: "test-user-id",
|
||||||
|
scopes: ["commander", "viewer"],
|
||||||
|
});
|
||||||
|
|
||||||
requireAuthStub.restore();
|
validateSessionStub.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => {
|
Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => {
|
||||||
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
||||||
const requireAuthStub = stub(
|
|
||||||
sdk,
|
|
||||||
"requireAuth",
|
|
||||||
() => Promise.reject(new Error("Unauthorized")),
|
|
||||||
);
|
|
||||||
|
|
||||||
const app = new Hono<{ Variables: Variables }>();
|
const app = new Hono<{ Variables: Variables }>();
|
||||||
app.use("*", createAuthMiddleware(sdk));
|
app.use("*", createAuthMiddleware(sdk));
|
||||||
@ -51,17 +58,15 @@ Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => {
|
|||||||
const res = await app.request(req);
|
const res = await app.request(req);
|
||||||
assertEquals(res.status, 401);
|
assertEquals(res.status, 401);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
assertEquals(json, { error: "Unauthorized" });
|
assertEquals(json, { error: "Unauthorized: Missing session token" });
|
||||||
|
|
||||||
requireAuthStub.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => {
|
Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => {
|
||||||
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
||||||
const requireAuthStub = stub(
|
const validateSessionStub = stub(
|
||||||
sdk,
|
sdk,
|
||||||
"requireAuth",
|
"validateSession",
|
||||||
() => Promise.reject(new Error("Unauthorized")),
|
() => Promise.resolve({ valid: false, error: "Invalid token" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const app = new Hono<{ Variables: Variables }>();
|
const app = new Hono<{ Variables: Variables }>();
|
||||||
@ -77,7 +82,59 @@ Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => {
|
|||||||
const res = await app.request(req);
|
const res = await app.request(req);
|
||||||
assertEquals(res.status, 401);
|
assertEquals(res.status, 401);
|
||||||
const json = await res.json();
|
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.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
36
sdk/hono.ts
36
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 { getCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import type { AuthSdk } from "./mod.ts";
|
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) {
|
export function createAuthMiddleware(sdk: AuthSdk) {
|
||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
let token = getCookie(c, "session_id");
|
let token = getCookie(c, "session_id");
|
||||||
@ -14,15 +19,40 @@ export function createAuthMiddleware(sdk: AuthSdk) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized: Missing session token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userId = await sdk.requireAuth(token);
|
const session = await sdk.validateSession(token);
|
||||||
c.set("userId", userId);
|
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();
|
await next();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createAuthSdk } from "./mod.ts";
|
|||||||
Deno.test("AuthSdk - initializes with config", () => {
|
Deno.test("AuthSdk - initializes with config", () => {
|
||||||
const sdk = createAuthSdk({
|
const sdk = createAuthSdk({
|
||||||
authApiUrl: "http://localhost:8000",
|
authApiUrl: "http://localhost:8000",
|
||||||
|
timeoutMs: 3000,
|
||||||
});
|
});
|
||||||
assertEquals(typeof sdk.validateSession, "function");
|
assertEquals(typeof sdk.validateSession, "function");
|
||||||
assertEquals(typeof sdk.requireAuth, "function");
|
assertEquals(typeof sdk.requireAuth, "function");
|
||||||
|
|||||||
18
sdk/mod.ts
18
sdk/mod.ts
@ -37,6 +37,10 @@ export interface AuthSdkConfig {
|
|||||||
* The mTLS CA certificate.
|
* The mTLS CA certificate.
|
||||||
*/
|
*/
|
||||||
tlsCa?: string;
|
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).
|
* @param token The opaque session token (e.g., extracted from a cookie).
|
||||||
* @returns The validated session data containing the UUID and scopes.
|
* @returns The validated session data containing the UUID and scopes.
|
||||||
*/
|
*/
|
||||||
async validateSession(token: string): Promise<SessionData> {
|
async validateSession(
|
||||||
|
token: string,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<SessionData> {
|
||||||
// Check L1 cache first
|
// Check L1 cache first
|
||||||
const cachedSession = this.l1Cache.get(token);
|
const cachedSession = this.l1Cache.get(token);
|
||||||
if (cachedSession) {
|
if (cachedSession) {
|
||||||
@ -150,12 +157,17 @@ export class AuthSdk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 = {
|
const sessionData: SessionData = {
|
||||||
valid: response.valid,
|
valid: response.valid,
|
||||||
uuid: response.uuid,
|
uuid: response.uuid,
|
||||||
scopes: response.scopes,
|
scopes: response.scopes || [],
|
||||||
error: response.error,
|
error: response.error,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user