Compare commits
No commits in common. "3c57dfbc781c7c25a65469562882e055d4c844e7" and "e9b9088fec2ed84810a401fb2829b8f19645f19d" have entirely different histories.
3c57dfbc78
...
e9b9088fec
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,4 +9,3 @@ node_modules/
|
|||||||
|
|
||||||
target/
|
target/
|
||||||
wasm/sss_recovery/target/
|
wasm/sss_recovery/target/
|
||||||
cov_profile/
|
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -9,15 +9,6 @@ export interface AuthenticatedUser {
|
|||||||
username: string;
|
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)
|
* 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.).
|
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
||||||
@ -105,7 +96,7 @@ export async function getAuthenticatedUser(
|
|||||||
*/
|
*/
|
||||||
export async function getAppByHost(
|
export async function getAppByHost(
|
||||||
host: string,
|
host: string,
|
||||||
): Promise<AppRecord | null> {
|
): Promise<{ id: string; name: string } | null> {
|
||||||
const cacheKey = `auth:app_by_host:${host}`;
|
const cacheKey = `auth:app_by_host:${host}`;
|
||||||
|
|
||||||
// 1. Try Valkey cache
|
// 1. Try Valkey cache
|
||||||
@ -122,8 +113,7 @@ export async function getAppByHost(
|
|||||||
try {
|
try {
|
||||||
// Search by domain first
|
// Search by domain first
|
||||||
let app = await sqlWrapper.sql`
|
let app = await sqlWrapper.sql`
|
||||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
SELECT id, name FROM apps WHERE domain = ${host}
|
||||||
FROM apps WHERE domain = ${host}
|
|
||||||
`.then((res: any) => res[0]);
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
// Fallback: match name against the first subdomain segment
|
// Fallback: match name against the first subdomain segment
|
||||||
@ -131,8 +121,7 @@ export async function getAppByHost(
|
|||||||
const subdomain = host.split(".")[0];
|
const subdomain = host.split(".")[0];
|
||||||
if (subdomain) {
|
if (subdomain) {
|
||||||
app = await sqlWrapper.sql`
|
app = await sqlWrapper.sql`
|
||||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
SELECT id, name FROM apps WHERE name = ${subdomain}
|
||||||
FROM apps WHERE name = ${subdomain}
|
|
||||||
`.then((res: any) => res[0]);
|
`.then((res: any) => res[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,7 +131,7 @@ export async function getAppByHost(
|
|||||||
try {
|
try {
|
||||||
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
||||||
} catch (_e) {}
|
} catch (_e) {}
|
||||||
return app as AppRecord;
|
return app;
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
return null;
|
return null;
|
||||||
@ -228,113 +217,3 @@ export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
|||||||
|
|
||||||
return false;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -69,15 +69,6 @@ export async function initDb(): Promise<void> {
|
|||||||
// Soft ignore if column already exists
|
// 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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { assert, assertEquals, assertExists } from "jsr:@std/assert";
|
import { 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";
|
||||||
@ -371,7 +371,7 @@ Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => {
|
|||||||
const res = await app.fetch(req);
|
const res = await app.fetch(req);
|
||||||
assertEquals(res.status, 400);
|
assertEquals(res.status, 400);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
assertEquals(json.error, "inviteCode or upgrade_session required");
|
assertEquals(json.error, "inviteCode required");
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => {
|
Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => {
|
||||||
@ -426,244 +426,5 @@ Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domai
|
|||||||
assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org");
|
assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org");
|
||||||
assertEquals(getCookieDomain("atyg.org"), ".atyg.org");
|
assertEquals(getCookieDomain("atyg.org"), ".atyg.org");
|
||||||
assertEquals(getCookieDomain("localhost"), undefined);
|
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
210
server/main.ts
210
server/main.ts
@ -352,40 +352,13 @@ app.post("/api/register/challenge", async (c) => {
|
|||||||
return c.json({ options, username });
|
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
|
// Verify registration and create UUID/session
|
||||||
app.post("/api/register/verify", async (c) => {
|
app.post("/api/register/verify", async (c) => {
|
||||||
try {
|
try {
|
||||||
const { response, username, inviteCode, upgrade_session } = await c.req
|
const { response, username, inviteCode } = await c.req.json();
|
||||||
.json();
|
|
||||||
|
|
||||||
if (!inviteCode && !upgrade_session) {
|
if (!inviteCode) {
|
||||||
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
|
return c.json({ error: "inviteCode required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
||||||
@ -512,97 +485,55 @@ app.post("/api/register/verify", async (c) => {
|
|||||||
prfSalt = encodeBase64Url(saltBytes);
|
prfSalt = encodeBase64Url(saltBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (upgrade_session) {
|
// Validate invite code at verification time to prevent race conditions
|
||||||
// Ephemeral Guest Sandbox in-flight promotion
|
const invite = await sqlWrapper
|
||||||
const sessionDataStr = await valkey.get(upgrade_session);
|
.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()`
|
||||||
if (!sessionDataStr) {
|
.then((res: any) => res[0]);
|
||||||
return c.json({ error: "Invalid or expired guest session" }, 400);
|
if (!invite) {
|
||||||
}
|
return c.json(
|
||||||
const sessionData = JSON.parse(sessionDataStr);
|
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||||
if (
|
400,
|
||||||
!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 initialStatus = invite.auto_activate === false ? "pending" : "active";
|
||||||
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
const insertRes = await sqlWrapper
|
||||||
|
.sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) 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})
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
UPDATE invites
|
||||||
|
SET uses_count = uses_count + 1,
|
||||||
|
used_at = NOW(),
|
||||||
|
used_by = ${user.id}
|
||||||
|
WHERE id = ${invite.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO invite_redemptions (invite_id, user_id)
|
||||||
|
VALUES (${invite.id}, ${user.id})
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (invite.app_id) {
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO sessions (id, user_id, expires_at)
|
INSERT INTO grants (user_id, app_id, role)
|
||||||
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
||||||
`;
|
`;
|
||||||
} else {
|
} else if (invite.role === "admin") {
|
||||||
// Standard Registration Flow
|
const adminApp = await sqlWrapper
|
||||||
const invite = await sqlWrapper
|
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
||||||
.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]);
|
.then((res: any) => res[0]);
|
||||||
if (!invite) {
|
if (adminApp) {
|
||||||
return c.json(
|
|
||||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
|
|
||||||
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})
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
|
||||||
UPDATE invites
|
|
||||||
SET uses_count = uses_count + 1,
|
|
||||||
used_at = NOW(),
|
|
||||||
used_by = ${user.id}
|
|
||||||
WHERE id = ${invite.id}
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
|
||||||
INSERT INTO invite_redemptions (invite_id, user_id)
|
|
||||||
VALUES (${invite.id}, ${user.id})
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (invite.app_id) {
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO grants (user_id, app_id, role)
|
INSERT INTO grants (user_id, app_id, role)
|
||||||
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
||||||
|
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
||||||
`;
|
`;
|
||||||
} else if (invite.role === "admin") {
|
|
||||||
const adminApp = await sqlWrapper
|
|
||||||
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
|
||||||
.then((res: any) => res[0]);
|
|
||||||
if (adminApp) {
|
|
||||||
await sqlWrapper.sql`
|
|
||||||
INSERT INTO grants (user_id, app_id, role)
|
|
||||||
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
|
||||||
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1091,12 +1022,7 @@ app.post("/api/admin/users/:id/status", async (c) => {
|
|||||||
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
|
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
import {
|
import { getAppByHost, getUserGrant } from "./auth-session.ts";
|
||||||
getAppByHost,
|
|
||||||
getUserGrant,
|
|
||||||
isIpAllowed,
|
|
||||||
isPathBypassed,
|
|
||||||
} from "./auth-session.ts";
|
|
||||||
import {
|
import {
|
||||||
computeJwkThumbprint,
|
computeJwkThumbprint,
|
||||||
verifyHttpSignature,
|
verifyHttpSignature,
|
||||||
@ -1111,34 +1037,8 @@ app.get("/api/forward-auth", async (c) => {
|
|||||||
// 1. Resolve Target App (Valkey -> DB)
|
// 1. Resolve Target App (Valkey -> DB)
|
||||||
const appRecord = await getAppByHost(host);
|
const appRecord = await getAppByHost(host);
|
||||||
if (!appRecord) {
|
if (!appRecord) {
|
||||||
const accept = c.req.header("Accept") || "";
|
// Default-Deny if app is not registered
|
||||||
// If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view
|
return c.text("Forbidden: Application not registered", 403);
|
||||||
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
|
// 2. Validate Session OR HTTP Signature
|
||||||
@ -1252,27 +1152,17 @@ app.post("/api/admin/apps", async (c) => {
|
|||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { name, spiffeId, description } = await c.req.json();
|
||||||
name,
|
|
||||||
spiffeId,
|
|
||||||
description,
|
|
||||||
domain,
|
|
||||||
is_public,
|
|
||||||
bypass_paths,
|
|
||||||
allowed_cidrs,
|
|
||||||
} = await c.req.json();
|
|
||||||
if (!name || !spiffeId) {
|
if (!name || !spiffeId) {
|
||||||
return c.json({ error: "Name and SPIFFE ID are required" }, 400);
|
return c.json({ error: "Name and SPIFFE ID are required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newApp = await sqlWrapper.sql`
|
const newApp = await sqlWrapper.sql`
|
||||||
INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs)
|
INSERT INTO apps (name, spiffe_id, description)
|
||||||
VALUES (${name.trim()}, ${spiffeId.trim()}, ${
|
VALUES (${name.trim()}, ${spiffeId.trim()}, ${
|
||||||
description?.trim() || null
|
description?.trim() || null
|
||||||
}, ${domain?.trim() || null}, ${is_public || false}, ${
|
})
|
||||||
bypass_paths || []
|
|
||||||
}, ${allowed_cidrs || []})
|
|
||||||
RETURNING id, name, spiffe_id, description, created_at
|
RETURNING id, name, spiffe_id, description, created_at
|
||||||
`.then((res: any) => res[0]);
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
|
|||||||
@ -81,58 +81,6 @@ export const AdminAppsPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<button type="submit" class="btn-action btn-success">
|
<button type="submit" class="btn-action btn-success">
|
||||||
Save Application
|
Save Application
|
||||||
@ -235,10 +183,6 @@ export const AdminAppsPage = ({
|
|||||||
const name = document.getElementById('appName').value.trim();
|
const name = document.getElementById('appName').value.trim();
|
||||||
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
const spiffeId = document.getElementById('appSpiffeId').value.trim();
|
||||||
const description = document.getElementById('appDescription').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) {
|
if (!name || !spiffeId) {
|
||||||
showNotice('Name and SPIFFE ID are required', true);
|
showNotice('Name and SPIFFE ID are required', true);
|
||||||
@ -249,7 +193,7 @@ export const AdminAppsPage = ({
|
|||||||
const res = await fetch('/api/admin/apps', {
|
const res = await fetch('/api/admin/apps', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, spiffeId, description, domain, is_public, bypass_paths, allowed_cidrs }),
|
body: JSON.stringify({ name, spiffeId, description }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -10,7 +10,7 @@ export const AuthenticatedLayout = ({
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Launchpad", href: "/dashboard" },
|
{ label: "Dashboard", 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,8 +168,7 @@ 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
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
74
ui/mod.ts
74
ui/mod.ts
@ -7,9 +7,7 @@ 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";
|
||||||
@ -23,8 +21,6 @@ import { RecoveryPage } from "./components/RecoveryPage.tsx";
|
|||||||
import { AdminAppsPage } from "./components/AdminAppsPage.tsx";
|
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 { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
|
|
||||||
|
|
||||||
const uiApp: Hono = new Hono();
|
const uiApp: Hono = new Hono();
|
||||||
|
|
||||||
@ -35,19 +31,8 @@ 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) {
|
||||||
@ -55,28 +40,6 @@ 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);
|
||||||
|
|
||||||
@ -96,9 +59,6 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -106,11 +66,6 @@ uiApp.get("/login", (c) => {
|
|||||||
return c.html(LoginPage());
|
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) => {
|
uiApp.get("/recovery", (c) => {
|
||||||
return c.html(RecoveryPage());
|
return c.html(RecoveryPage());
|
||||||
});
|
});
|
||||||
@ -120,33 +75,8 @@ uiApp.get("/register", (c) => {
|
|||||||
return c.html(RegisterPage({ initialCode }));
|
return c.html(RegisterPage({ initialCode }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/dashboard", async (c) => {
|
uiApp.get("/dashboard", (c) => {
|
||||||
const auth = await getAuthenticatedUser(c);
|
return c.redirect("/dashboard/sessions");
|
||||||
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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user