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/
|
target/
|
||||||
wasm/sss_recovery/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;
|
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 { 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";
|
||||||
@ -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 () => {
|
Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => {
|
||||||
const { getCookieDomain } = await import("./auth-session.ts");
|
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("auth.atyg.org"), ".atyg.org");
|
||||||
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);
|
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;
|
isAdmin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ label: "Launchpad", 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,7 +168,8 @@ 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
|
||||||
|
|||||||
68
ui/mod.ts
68
ui/mod.ts
@ -7,7 +7,9 @@ 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";
|
||||||
@ -21,6 +23,7 @@ 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 { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
|
||||||
|
|
||||||
const uiApp: Hono = new Hono();
|
const uiApp: Hono = new Hono();
|
||||||
|
|
||||||
@ -31,8 +34,19 @@ 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) {
|
||||||
@ -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 rpID = Deno.env.get("RP_ID") || "";
|
||||||
const cookieDomain = getCookieDomain(rpID);
|
const cookieDomain = getCookieDomain(rpID);
|
||||||
|
|
||||||
@ -59,6 +95,9 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,8 +114,33 @@ uiApp.get("/register", (c) => {
|
|||||||
return c.html(RegisterPage({ initialCode }));
|
return c.html(RegisterPage({ initialCode }));
|
||||||
});
|
});
|
||||||
|
|
||||||
uiApp.get("/dashboard", (c) => {
|
uiApp.get("/dashboard", async (c) => {
|
||||||
return c.redirect("/dashboard/sessions");
|
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) => {
|
uiApp.get("/dashboard/sessions", async (c) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user