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/
|
||||
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;
|
||||
}
|
||||
|
||||
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.).
|
||||
@ -105,7 +96,7 @@ export async function getAuthenticatedUser(
|
||||
*/
|
||||
export async function getAppByHost(
|
||||
host: string,
|
||||
): Promise<AppRecord | null> {
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const cacheKey = `auth:app_by_host:${host}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
@ -122,8 +113,7 @@ export async function getAppByHost(
|
||||
try {
|
||||
// Search by domain first
|
||||
let app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE domain = ${host}
|
||||
SELECT id, name FROM apps WHERE domain = ${host}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
// Fallback: match name against the first subdomain segment
|
||||
@ -131,8 +121,7 @@ export async function getAppByHost(
|
||||
const subdomain = host.split(".")[0];
|
||||
if (subdomain) {
|
||||
app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE name = ${subdomain}
|
||||
SELECT id, name FROM apps WHERE name = ${subdomain}
|
||||
`.then((res: any) => res[0]);
|
||||
}
|
||||
}
|
||||
@ -142,7 +131,7 @@ export async function getAppByHost(
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return app as AppRecord;
|
||||
return app;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
@ -228,113 +217,3 @@ 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;
|
||||
}
|
||||
|
||||
@ -69,15 +69,6 @@ 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(),
|
||||
|
||||
@ -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 { 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 or upgrade_session required");
|
||||
assertEquals(json.error, "inviteCode required");
|
||||
});
|
||||
|
||||
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("atyg.org"), ".atyg.org");
|
||||
assertEquals(getCookieDomain("localhost"), 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;
|
||||
}
|
||||
});
|
||||
assertEquals(getCookieDomain(""), undefined);
|
||||
});
|
||||
|
||||
210
server/main.ts
210
server/main.ts
@ -352,40 +352,13 @@ 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, upgrade_session } = await c.req
|
||||
.json();
|
||||
const { response, username, inviteCode } = await c.req.json();
|
||||
|
||||
if (!inviteCode && !upgrade_session) {
|
||||
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
|
||||
if (!inviteCode) {
|
||||
return c.json({ error: "inviteCode required" }, 400);
|
||||
}
|
||||
|
||||
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
||||
@ -512,97 +485,55 @@ app.post("/api/register/verify", async (c) => {
|
||||
prfSalt = encodeBase64Url(saltBytes);
|
||||
}
|
||||
|
||||
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" }),
|
||||
// Validate invite code at verification time to prevent race conditions
|
||||
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]);
|
||||
if (!invite) {
|
||||
return c.json(
|
||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Register session in PostgreSQL
|
||||
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
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`
|
||||
INSERT INTO sessions (id, user_id, expires_at)
|
||||
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
||||
`;
|
||||
} 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()`
|
||||
} 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 (!invite) {
|
||||
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) {
|
||||
if (adminApp) {
|
||||
await sqlWrapper.sql`
|
||||
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)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
import {
|
||||
getAppByHost,
|
||||
getUserGrant,
|
||||
isIpAllowed,
|
||||
isPathBypassed,
|
||||
} from "./auth-session.ts";
|
||||
import { getAppByHost, getUserGrant } from "./auth-session.ts";
|
||||
import {
|
||||
computeJwkThumbprint,
|
||||
verifyHttpSignature,
|
||||
@ -1111,34 +1037,8 @@ app.get("/api/forward-auth", async (c) => {
|
||||
// 1. Resolve Target App (Valkey -> DB)
|
||||
const appRecord = await getAppByHost(host);
|
||||
if (!appRecord) {
|
||||
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);
|
||||
// Default-Deny if app is not registered
|
||||
return c.text("Forbidden: Application not registered", 403);
|
||||
}
|
||||
|
||||
// 2. Validate Session OR HTTP Signature
|
||||
@ -1252,27 +1152,17 @@ app.post("/api/admin/apps", async (c) => {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
spiffeId,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
const { name, spiffeId, description } = 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, domain, is_public, bypass_paths, allowed_cidrs)
|
||||
INSERT INTO apps (name, spiffe_id, description)
|
||||
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]);
|
||||
|
||||
|
||||
@ -81,58 +81,6 @@ 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
|
||||
@ -235,10 +183,6 @@ 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);
|
||||
@ -249,7 +193,7 @@ export const AdminAppsPage = ({
|
||||
const res = await fetch('/api/admin/apps', {
|
||||
method: 'POST',
|
||||
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();
|
||||
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;
|
||||
}) => {
|
||||
const navItems = [
|
||||
{ label: "Launchpad", href: "/dashboard" },
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Sessions", href: "/dashboard/sessions" },
|
||||
{ label: "Passkeys", href: "/dashboard/passkeys" },
|
||||
...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []),
|
||||
@ -168,8 +168,7 @@ export const AuthenticatedLayout = ({
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.href === "/dashboard"
|
||||
? currentPath === "/dashboard"
|
||||
: currentPath.startsWith(item.href) &&
|
||||
item.href !== "/dashboard";
|
||||
: currentPath.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<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,
|
||||
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";
|
||||
@ -23,8 +21,6 @@ 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();
|
||||
|
||||
@ -35,19 +31,8 @@ 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) {
|
||||
@ -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 cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
@ -96,9 +59,6 @@ uiApp.get("/logout", async (c) => {
|
||||
sameSite: "Lax",
|
||||
});
|
||||
|
||||
if (safeRedirect) {
|
||||
return c.redirect(`/login?redirect=${encodeURIComponent(safeRedirect)}`);
|
||||
}
|
||||
return c.redirect("/login");
|
||||
});
|
||||
|
||||
@ -106,11 +66,6 @@ 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());
|
||||
});
|
||||
@ -120,33 +75,8 @@ uiApp.get("/register", (c) => {
|
||||
return c.html(RegisterPage({ initialCode }));
|
||||
});
|
||||
|
||||
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", (c) => {
|
||||
return c.redirect("/dashboard/sessions");
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard/sessions", async (c) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user