Hono app serving as the login UI and admin panel for Ory Kratos + Hydra. Handles OIDC consent/login flows, session management, avatar uploads, and proxies Kratos admin/public APIs.
290 lines
8.6 KiB
TypeScript
290 lines
8.6 KiB
TypeScript
import type { Context, Next } from "hono";
|
|
|
|
const KRATOS_PUBLIC_URL =
|
|
Deno.env.get("KRATOS_PUBLIC_URL") ??
|
|
"http://kratos-public.ory.svc.cluster.local:80";
|
|
const PUBLIC_URL = Deno.env.get("PUBLIC_URL") ?? "http://localhost:3000";
|
|
const KRATOS_ADMIN_URL =
|
|
Deno.env.get("KRATOS_ADMIN_URL") ??
|
|
"http://kratos-admin.ory.svc.cluster.local:80";
|
|
|
|
// Routes that require no authentication at all
|
|
const PUBLIC_ROUTES = new Set([
|
|
"/login",
|
|
"/registration",
|
|
"/recovery",
|
|
"/verification",
|
|
"/error",
|
|
"/health",
|
|
]);
|
|
|
|
// Routes that need CSRF but not a session (Hydra flows)
|
|
const CSRF_ONLY_ROUTES = new Set(["/consent", "/logout"]);
|
|
|
|
function getAdminList(): string[] {
|
|
const raw = Deno.env.get("ADMIN_IDENTITY_IDS") ?? "";
|
|
if (!raw.trim()) return [];
|
|
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
function extractSessionCookie(cookieHeader: string): string | null {
|
|
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
|
for (const cookie of cookies) {
|
|
if (
|
|
cookie.startsWith("ory_session_") ||
|
|
cookie.startsWith("ory_kratos_session")
|
|
) {
|
|
return cookie;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface SessionInfo {
|
|
id: string;
|
|
email: string;
|
|
session: unknown;
|
|
}
|
|
|
|
export interface SessionResult {
|
|
info: SessionInfo | null;
|
|
needsAal2: boolean;
|
|
redirectTo?: string;
|
|
}
|
|
|
|
/** Fetch the Kratos session for the given cookie. */
|
|
export async function getSession(
|
|
cookieHeader: string,
|
|
): Promise<SessionResult> {
|
|
const sessionCookie = extractSessionCookie(cookieHeader);
|
|
if (!sessionCookie) return { info: null, needsAal2: false };
|
|
|
|
try {
|
|
const resp = await fetch(`${KRATOS_PUBLIC_URL}/sessions/whoami`, {
|
|
headers: { cookie: sessionCookie },
|
|
});
|
|
if (resp.status === 403) {
|
|
// Session exists but AAL is too low — need 2FA step-up
|
|
const body = await resp.json().catch(() => null);
|
|
const redirectTo = body?.redirect_browser_to ?? body?.error?.details?.redirect_browser_to;
|
|
return { info: null, needsAal2: true, redirectTo };
|
|
}
|
|
if (resp.status !== 200) return { info: null, needsAal2: false };
|
|
const session = await resp.json();
|
|
return {
|
|
info: {
|
|
id: session?.identity?.id ?? "",
|
|
email: session?.identity?.traits?.email ?? "",
|
|
session,
|
|
},
|
|
needsAal2: false,
|
|
};
|
|
} catch {
|
|
return { info: null, needsAal2: false };
|
|
}
|
|
}
|
|
|
|
/** Check if an identity has any 2FA method (TOTP or WebAuthn) configured. */
|
|
async function has2fa(identityId: string): Promise<boolean> {
|
|
try {
|
|
const resp = await fetch(
|
|
`${KRATOS_ADMIN_URL}/admin/identities/${identityId}?include_credential=totp&include_credential=webauthn`,
|
|
);
|
|
if (!resp.ok) return true; // fail open — don't block on admin API errors
|
|
const identity = await resp.json();
|
|
const creds = identity?.credentials ?? {};
|
|
const hasTOTP = creds.totp?.identifiers?.length > 0;
|
|
const hasWebAuthn = creds.webauthn?.identifiers?.length > 0;
|
|
return hasTOTP || hasWebAuthn;
|
|
} catch {
|
|
return true; // fail open
|
|
}
|
|
}
|
|
|
|
export function isAdmin(identityId: string, email: string): boolean {
|
|
const adminList = getAdminList();
|
|
if (adminList.length === 0) return true; // bootstrap mode
|
|
return adminList.includes(identityId) || adminList.includes(email);
|
|
}
|
|
|
|
function isPublicRoute(path: string): boolean {
|
|
// Exact match or path starts with the public route + /
|
|
for (const route of PUBLIC_ROUTES) {
|
|
if (path === route || path.startsWith(route + "/")) return true;
|
|
}
|
|
// Flow proxy and flow error endpoints are public (need cookies but not session validation here)
|
|
if (path.startsWith("/api/flow/")) return true;
|
|
// Static assets must be served without auth so the SPA can load
|
|
if (path.startsWith("/assets/") || path === "/index.html" || path === "/favicon.ico") return true;
|
|
return false;
|
|
}
|
|
|
|
function isCsrfOnlyRoute(path: string): boolean {
|
|
for (const route of CSRF_ONLY_ROUTES) {
|
|
if (path === route || path.startsWith(route + "/")) return true;
|
|
}
|
|
// Hydra proxy endpoints
|
|
if (path.startsWith("/api/hydra/")) return true;
|
|
return false;
|
|
}
|
|
|
|
function isAdminRoute(path: string): boolean {
|
|
// Admin API proxy (Kratos admin) and admin-only UI routes
|
|
const adminPrefixes = [
|
|
"/api/identities",
|
|
"/api/admin",
|
|
"/api/sessions",
|
|
"/api/courier",
|
|
"/identities",
|
|
"/sessions",
|
|
"/courier",
|
|
"/schemas",
|
|
];
|
|
for (const prefix of adminPrefixes) {
|
|
if (path === prefix || path.startsWith(prefix + "/") ||
|
|
path.startsWith(prefix + "?")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function authMiddleware(c: Context, next: Next) {
|
|
const path = c.req.path;
|
|
|
|
// Public routes: no auth needed
|
|
if (isPublicRoute(path)) {
|
|
return await next();
|
|
}
|
|
|
|
// CSRF-only routes: skip session check
|
|
if (isCsrfOnlyRoute(path)) {
|
|
return await next();
|
|
}
|
|
|
|
// All other routes need authentication
|
|
const cookieHeader = c.req.header("cookie") ?? "";
|
|
const { info: sessionInfo, needsAal2, redirectTo } = await getSession(
|
|
cookieHeader,
|
|
);
|
|
|
|
if (needsAal2) {
|
|
// Session exists but needs 2FA — redirect to step-up login
|
|
if (
|
|
path.startsWith("/api/") ||
|
|
c.req.header("accept")?.includes("application/json")
|
|
) {
|
|
return c.json({ error: "AAL2 required", redirectTo }, 403);
|
|
}
|
|
// Use Kratos-provided redirect URL if available, otherwise construct one
|
|
if (redirectTo) {
|
|
return c.redirect(redirectTo, 302);
|
|
}
|
|
const returnTo = encodeURIComponent(PUBLIC_URL + path);
|
|
return c.redirect(
|
|
`/kratos/self-service/login/browser?aal=aal2&return_to=${returnTo}`,
|
|
302,
|
|
);
|
|
}
|
|
|
|
if (!sessionInfo) {
|
|
// API requests get 401, browser requests get redirected
|
|
if (
|
|
path.startsWith("/api/") ||
|
|
c.req.header("accept")?.includes("application/json")
|
|
) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
const loginUrl =
|
|
`${PUBLIC_URL}/login?return_to=${encodeURIComponent(PUBLIC_URL + path)}`;
|
|
return c.redirect(loginUrl, 302);
|
|
}
|
|
|
|
c.set("identity", {
|
|
id: sessionInfo.id,
|
|
email: sessionInfo.email,
|
|
session: sessionInfo.session,
|
|
});
|
|
c.set("isAdmin", isAdmin(sessionInfo.id, sessionInfo.email));
|
|
|
|
// 2FA enrollment check — force users to set up TOTP/WebAuthn before using the app.
|
|
// Allow /security, /api/auth/*, /api/flow/*, and /kratos/* through so they can
|
|
// actually complete the setup flow.
|
|
const skipMfaCheck = path === "/onboarding" || path.startsWith("/onboarding/") ||
|
|
path.startsWith("/api/auth/") || path.startsWith("/api/flow/") ||
|
|
path.startsWith("/api/avatar/") || path.startsWith("/api/health/") ||
|
|
path === "/health" || path.startsWith("/kratos/");
|
|
if (!skipMfaCheck) {
|
|
const userHas2fa = await has2fa(sessionInfo.id);
|
|
c.set("has2fa", userHas2fa);
|
|
if (!userHas2fa) {
|
|
if (
|
|
path.startsWith("/api/") ||
|
|
c.req.header("accept")?.includes("application/json")
|
|
) {
|
|
return c.json({ error: "2FA setup required", needs2faSetup: true }, 403);
|
|
}
|
|
return c.redirect("/onboarding", 302);
|
|
}
|
|
}
|
|
|
|
// Admin-only routes: check admin status
|
|
if (isAdminRoute(path)) {
|
|
if (!c.get("isAdmin")) {
|
|
if (
|
|
path.startsWith("/api/") ||
|
|
c.req.header("accept")?.includes("application/json")
|
|
) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
return c.redirect("/settings", 302);
|
|
}
|
|
}
|
|
|
|
await next();
|
|
}
|
|
|
|
/** DELETE /api/auth/sessions — revokes ALL sessions for the current identity. */
|
|
export async function revokeAllSessionsHandler(c: Context): Promise<Response> {
|
|
const identity = c.get("identity");
|
|
if (!identity?.id) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(
|
|
`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}/sessions`,
|
|
{ method: "DELETE" },
|
|
);
|
|
if (resp.status === 204 || resp.status === 200) {
|
|
return c.json({ ok: true });
|
|
}
|
|
return c.json({ error: "Failed to revoke sessions" }, 500);
|
|
} catch {
|
|
return c.json({ error: "Failed to revoke sessions" }, 500);
|
|
}
|
|
}
|
|
|
|
/** GET /api/auth/session — returns current session + admin status. */
|
|
export async function sessionHandler(c: Context): Promise<Response> {
|
|
const cookieHeader = c.req.header("cookie") ?? "";
|
|
const { info: sessionInfo, needsAal2, redirectTo } = await getSession(
|
|
cookieHeader,
|
|
);
|
|
|
|
if (needsAal2) {
|
|
return c.json({ error: "AAL2 required", needsAal2: true, redirectTo }, 403);
|
|
}
|
|
|
|
if (!sessionInfo) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const userHas2fa = await has2fa(sessionInfo.id);
|
|
return c.json({
|
|
session: sessionInfo.session,
|
|
isAdmin: isAdmin(sessionInfo.id, sessionInfo.email),
|
|
needs2faSetup: !userHas2fa,
|
|
});
|
|
}
|