Merge pull request #22 from mrteye/feat-sso-launchpad-logout-2100-7262934389430052995
feat(ui): implement App Launchpad and secure logout redirect This PR implements the requested App Launchpad feature and secures the logout process against open-redirect attacks. Key changes: 1. **Logout Return-Path Validation**: Added `isSafeRedirectUrl` and updated the `GET /logout` handler. Unsafe redirects are logged as `open_redirect_intercepted`, while successful logouts generate `logout_success` events on the Merkle ledger. All host and parent wildcard cookies (`getCookieDomain()`) are cleared to prevent state collisions. 2. **SSO Application Launchpad**: Introduced `ui/components/AppLaunchpadPage.tsx` using pure Hono SSR JSX. Updates `GET /dashboard` to render this page as the main authenticated landing view. Query logic uses strict Zero-Knowledge filtering—regular users only see explicitly granted apps, and Global Admins see all apps tagged with an `[Admin]` badge. 3. **Hybrid Ingress Playbook**: Authored `docs/HYBRID_INGRESS_PLAYBOOK.md` detailing the architectural split between Traefik ForwardAuth and client application SSR routing. 4. **Testing & QA**: Written comprehensive hermetic unit tests in `server/main.test.ts` for the redirect interceptor, standard logout logic, and Zero-Knowledge role queries. All tests pass with full quality gate compliance (`deno fmt`, `deno task lint`, `deno task check`).
This commit is contained in:
commit
544fdf0569
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ node_modules/
|
||||
|
||||
target/
|
||||
wasm/sss_recovery/target/
|
||||
cov_profile/
|
||||
|
||||
96
docs/HYBRID_INGRESS_PLAYBOOK.md
Normal file
96
docs/HYBRID_INGRESS_PLAYBOOK.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Hybrid Ingress Routing Playbook
|
||||
|
||||
This playbook outlines the recommended architecture for deploying consumer
|
||||
applications that require a combination of public-facing endpoints (like splash
|
||||
pages or APIs) and private, authenticated endpoints (like control panels or user
|
||||
dashboards), while integrating with Auth-Yes.
|
||||
|
||||
## Context
|
||||
|
||||
Many applications are not entirely public or entirely private. They utilize a
|
||||
hybrid approach where certain paths are accessible to anyone, while others are
|
||||
strictly protected. To achieve Zero-Trust security and streamline
|
||||
authentication, we use a dual-routing pattern leveraging Traefik and
|
||||
application-level SSR hydration.
|
||||
|
||||
## The Dual-Routing Pattern
|
||||
|
||||
The dual-routing pattern separates responsibilities between the edge proxy
|
||||
(Traefik) and the application itself.
|
||||
|
||||
### 1. Edge Proxy Authentication (Traefik ForwardAuth)
|
||||
|
||||
For paths that must be strictly private and require a valid user session (or
|
||||
machine identity), we use Traefik's `ForwardAuth` middleware. This delegates the
|
||||
authentication decision to the Auth-Yes edge node.
|
||||
|
||||
**Protected Paths:**
|
||||
|
||||
- `/control-panel/*`
|
||||
- `/dashboard/*`
|
||||
- `/ws/*` (WebSockets)
|
||||
- `/api/private/*`
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. A request arrives at Traefik for a protected path (e.g.,
|
||||
`/control-panel/settings`).
|
||||
2. Traefik intercepts the request and sends a sub-request to the Auth-Yes
|
||||
`ForwardAuth` endpoint (`/api/forward-auth`).
|
||||
3. Auth-Yes validates the `session_id` cookie or RFC 9421 HTTP Message
|
||||
Signature.
|
||||
4. If valid, Auth-Yes returns HTTP 200 OK, injecting context headers like
|
||||
`X-Forwarded-User` and `X-Forwarded-Scopes`.
|
||||
5. Traefik allows the original request to proceed to the application.
|
||||
6. If invalid, Auth-Yes intercepts with a redirect (for browsers) or 403
|
||||
Forbidden (for APIs/daemons).
|
||||
|
||||
**Example Traefik Configuration (Labels):**
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.myapp-private.rule=Host(`myapp.atyg.org`) && (PathPrefix(`/control-panel`) || PathPrefix(`/api/private`))"
|
||||
- "traefik.http.routers.myapp-private.middlewares=auth-yes-forwardauth"
|
||||
- "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.address=http://auth-api:8000/api/forward-auth"
|
||||
- "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.auth-yes-forwardauth.forwardauth.authResponseHeaders=X-Forwarded-User,X-Forwarded-Scopes"
|
||||
```
|
||||
|
||||
### 2. Application-Level Authentication (SSR Hydration)
|
||||
|
||||
For paths that are public or require custom application logic to handle
|
||||
unauthenticated users gracefully, the application itself handles the
|
||||
authentication state via SDKs or custom logic.
|
||||
|
||||
**Public/Hybrid Paths:**
|
||||
|
||||
- `/` (Splash page, landing page)
|
||||
- `/about`
|
||||
- `/api/public/*`
|
||||
- `/.well-known/*`
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. A request arrives at Traefik for a public path (e.g., `/`).
|
||||
2. Traefik routes the request directly to the application (no `ForwardAuth`
|
||||
middleware applied).
|
||||
3. The application receives the request. It can check for the presence of a
|
||||
`session_id` cookie if it wants to render personalized content (e.g.,
|
||||
replacing "Login" with "Go to Control Panel").
|
||||
4. If no session exists, it renders the public splash page.
|
||||
|
||||
**Example Traefik Configuration (Labels):**
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.myapp-public.rule=Host(`myapp.atyg.org`)"
|
||||
# No ForwardAuth middleware here
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
By combining Traefik `ForwardAuth` for strict edge-level protection of critical
|
||||
paths with application-level handling for public paths, we achieve a robust,
|
||||
flexible, and secure ingress architecture. This ensures that sensitive routes
|
||||
are never accidentally exposed, while public routes remain performant and
|
||||
accessible.
|
||||
@ -217,3 +217,38 @@ export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { assertEquals, assertExists } from "jsr:@std/assert";
|
||||
import { assert, assertEquals, assertExists } from "jsr:@std/assert";
|
||||
import { stub } from "jsr:@std/testing/mock";
|
||||
import { app } from "./main.ts";
|
||||
import { sqlWrapper } from "./db.ts";
|
||||
@ -422,9 +422,164 @@ Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () =
|
||||
Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => {
|
||||
const { getCookieDomain } = await import("./auth-session.ts");
|
||||
|
||||
const origRpId = Deno.env.get("RP_ID");
|
||||
Deno.env.delete("RP_ID");
|
||||
|
||||
try {
|
||||
assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org");
|
||||
assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org");
|
||||
assertEquals(getCookieDomain("atyg.org"), ".atyg.org");
|
||||
assertEquals(getCookieDomain("localhost"), undefined);
|
||||
assertEquals(getCookieDomain(""), undefined);
|
||||
} finally {
|
||||
if (origRpId !== undefined) {
|
||||
Deno.env.set("RP_ID", origRpId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
102
ui/components/AppLaunchpadPage.tsx
Normal file
102
ui/components/AppLaunchpadPage.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||||
|
||||
export interface AppCard {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const AppLaunchpadPage = ({
|
||||
apps,
|
||||
isAdmin,
|
||||
}: {
|
||||
apps: AppCard[];
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title="Launchpad"
|
||||
currentPath="/dashboard"
|
||||
isAdmin={isAdmin}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "2rem",
|
||||
}}
|
||||
>
|
||||
<h2>Application Launchpad</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
{apps.length === 0
|
||||
? (
|
||||
<div style={{ color: "#6c757d", gridColumn: "1 / -1" }}>
|
||||
No authorized applications found.
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
apps.map((app) => (
|
||||
<div
|
||||
class="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
marginBottom: "0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, color: "#212529" }}>{app.name}</h3>
|
||||
<span
|
||||
class={`badge ${
|
||||
app.role === "Admin" || app.role === "Global Admin"
|
||||
? "badge-success"
|
||||
: "badge-primary"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
app.role === "Admin" || app.role === "Global Admin"
|
||||
? "#28a745"
|
||||
: "#007bff",
|
||||
}}
|
||||
>
|
||||
{app.role}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ color: "#6c757d", flex: 1, fontSize: "0.9rem" }}>
|
||||
{app.description || "No description provided."}
|
||||
</p>
|
||||
<div style={{ marginTop: "1rem", textAlign: "right" }}>
|
||||
<a
|
||||
href={`https://${app.domain}`}
|
||||
class="btn-primary"
|
||||
style={{ textDecoration: "none", display: "inline-block" }}
|
||||
>
|
||||
Launch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@ -10,7 +10,7 @@ export const AuthenticatedLayout = ({
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const navItems = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Launchpad", href: "/dashboard" },
|
||||
{ label: "Sessions", href: "/dashboard/sessions" },
|
||||
{ label: "Passkeys", href: "/dashboard/passkeys" },
|
||||
...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []),
|
||||
@ -168,7 +168,8 @@ export const AuthenticatedLayout = ({
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.href === "/dashboard"
|
||||
? currentPath === "/dashboard"
|
||||
: currentPath.startsWith(item.href);
|
||||
: currentPath.startsWith(item.href) &&
|
||||
item.href !== "/dashboard";
|
||||
|
||||
return (
|
||||
<a
|
||||
|
||||
68
ui/mod.ts
68
ui/mod.ts
@ -7,7 +7,9 @@ import {
|
||||
getAuthenticatedUser,
|
||||
getCookieDomain,
|
||||
isGlobalAdmin,
|
||||
isSafeRedirectUrl,
|
||||
} from "../server/auth-session.ts";
|
||||
import { auditWrapper } from "../server/audit.ts";
|
||||
import { LoginPage } from "./components/LoginPage.tsx";
|
||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||
import { SessionsPage } from "./components/SessionsPage.tsx";
|
||||
@ -21,6 +23,7 @@ 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 { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
|
||||
|
||||
const uiApp: Hono = new Hono();
|
||||
|
||||
@ -31,8 +34,19 @@ uiApp.get("/", (c) => {
|
||||
|
||||
uiApp.get("/logout", async (c) => {
|
||||
const sessionId = getCookie(c, "session_id");
|
||||
const rawRedirect = c.req.query("redirect");
|
||||
let safeRedirect = null;
|
||||
const userIp = c.req.header("x-forwarded-for") || "127.0.0.1";
|
||||
let userId = null;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
// Get user ID for auditing before we delete the session
|
||||
const authUser = await getAuthenticatedUser(c);
|
||||
if (authUser) {
|
||||
userId = authUser.userId;
|
||||
}
|
||||
|
||||
await valkey.del(sessionId);
|
||||
await sql`DELETE FROM sessions WHERE id = ${sessionId}`;
|
||||
} catch (_e) {
|
||||
@ -40,6 +54,28 @@ uiApp.get("/logout", async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (rawRedirect) {
|
||||
if (isSafeRedirectUrl(rawRedirect)) {
|
||||
safeRedirect = rawRedirect;
|
||||
} else {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"open_redirect_intercepted",
|
||||
"logout_redirect",
|
||||
{ raw_url: rawRedirect },
|
||||
userIp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"logout_success",
|
||||
"session",
|
||||
null,
|
||||
userIp,
|
||||
);
|
||||
|
||||
const rpID = Deno.env.get("RP_ID") || "";
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
@ -59,6 +95,9 @@ uiApp.get("/logout", async (c) => {
|
||||
sameSite: "Lax",
|
||||
});
|
||||
|
||||
if (safeRedirect) {
|
||||
return c.redirect(`/login?redirect=${encodeURIComponent(safeRedirect)}`);
|
||||
}
|
||||
return c.redirect("/login");
|
||||
});
|
||||
|
||||
@ -75,8 +114,33 @@ uiApp.get("/register", (c) => {
|
||||
return c.html(RegisterPage({ initialCode }));
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard", (c) => {
|
||||
return c.redirect("/dashboard/sessions");
|
||||
uiApp.get("/dashboard", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
return c.redirect("/login");
|
||||
}
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
|
||||
let apps = [];
|
||||
if (isAdmin) {
|
||||
apps = await sql`
|
||||
SELECT id, name, description, domain, 'Admin' as role
|
||||
FROM apps
|
||||
WHERE domain IS NOT NULL
|
||||
ORDER BY name ASC
|
||||
` as any[];
|
||||
} else {
|
||||
apps = await sql`
|
||||
SELECT a.id, a.name, a.description, a.domain, g.role
|
||||
FROM apps a
|
||||
JOIN grants g ON a.id = g.app_id
|
||||
WHERE g.user_id = ${auth.userId} AND a.domain IS NOT NULL
|
||||
ORDER BY a.name ASC
|
||||
` as any[];
|
||||
}
|
||||
|
||||
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard/sessions", async (c) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user