Initial server: Deno/Hono backend with auth, CSRF, Hydra consent, and flow proxy
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.
This commit is contained in:
289
server/auth.ts
Normal file
289
server/auth.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
90
server/csrf.ts
Normal file
90
server/csrf.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
const CSRF_COOKIE_SECRET =
|
||||
Deno.env.get("CSRF_COOKIE_SECRET") ?? "dev-secret-change-in-production";
|
||||
const CSRF_COOKIE_NAME = "ory-csrf-token";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
async function hmacSign(data: string, secret: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function hmacVerify(
|
||||
data: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
const expected = await hmacSign(data, secret);
|
||||
if (expected.length !== signature.length) return false;
|
||||
// Constant-time compare
|
||||
let result = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
export async function generateCsrfToken(): Promise<{
|
||||
token: string;
|
||||
cookie: string;
|
||||
}> {
|
||||
const raw = crypto.randomUUID();
|
||||
const sig = await hmacSign(raw, CSRF_COOKIE_SECRET);
|
||||
const token = `${raw}.${sig}`;
|
||||
const cookie =
|
||||
`${CSRF_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict`;
|
||||
return { token, cookie };
|
||||
}
|
||||
|
||||
function extractCookie(cookieHeader: string, name: string): string | null {
|
||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.startsWith(`${name}=`)) {
|
||||
return cookie.slice(name.length + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function verifyCsrfToken(req: Request): Promise<boolean> {
|
||||
const headerToken = req.headers.get("x-csrf-token");
|
||||
if (!headerToken) return false;
|
||||
|
||||
const cookieHeader = req.headers.get("cookie") ?? "";
|
||||
const cookieToken = extractCookie(cookieHeader, CSRF_COOKIE_NAME);
|
||||
if (!cookieToken) return false;
|
||||
|
||||
// Both must match
|
||||
if (headerToken !== cookieToken) return false;
|
||||
|
||||
// Verify HMAC
|
||||
const parts = headerToken.split(".");
|
||||
if (parts.length !== 2) return false;
|
||||
const [raw, sig] = parts;
|
||||
return await hmacVerify(raw, sig, CSRF_COOKIE_SECRET);
|
||||
}
|
||||
|
||||
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
export async function csrfMiddleware(c: Context, next: Next) {
|
||||
// Only protect state-mutating methods on non-API routes
|
||||
// (API routes proxy to Kratos which has its own CSRF)
|
||||
if (MUTATING_METHODS.has(c.req.method) && !c.req.path.startsWith("/api")) {
|
||||
const valid = await verifyCsrfToken(c.req.raw);
|
||||
if (!valid) {
|
||||
return c.text("CSRF token invalid or missing", 403);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
77
server/flow.ts
Normal file
77
server/flow.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
const KRATOS_PUBLIC_URL =
|
||||
Deno.env.get("KRATOS_PUBLIC_URL") ??
|
||||
"http://kratos-public.ory.svc.cluster.local:80";
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
function stripHopByHop(headers: Headers): Headers {
|
||||
const out = new Headers();
|
||||
for (const [key, value] of headers.entries()) {
|
||||
if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "host") {
|
||||
out.set(key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** GET /api/flow/:type?flow=<id> — proxy Kratos self-service flow data. */
|
||||
export async function flowHandler(c: Context): Promise<Response> {
|
||||
const type = c.req.param("type");
|
||||
const flowId = c.req.query("flow");
|
||||
|
||||
if (!flowId) {
|
||||
return c.json({ error: "Missing flow query parameter" }, 400);
|
||||
}
|
||||
|
||||
const target =
|
||||
`${KRATOS_PUBLIC_URL}/self-service/${type}/flows?id=${flowId}`;
|
||||
|
||||
const reqHeaders = stripHopByHop(c.req.raw.headers);
|
||||
|
||||
try {
|
||||
const resp = await fetch(target, { headers: reqHeaders });
|
||||
const respHeaders = stripHopByHop(resp.headers);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: respHeaders,
|
||||
});
|
||||
} catch {
|
||||
return c.text("Kratos unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/flow/error?id=<id> — proxy Kratos self-service error. */
|
||||
export async function flowErrorHandler(c: Context): Promise<Response> {
|
||||
const errorId = c.req.query("id");
|
||||
|
||||
if (!errorId) {
|
||||
return c.json({ error: "Missing id query parameter" }, 400);
|
||||
}
|
||||
|
||||
const target = `${KRATOS_PUBLIC_URL}/self-service/errors?id=${errorId}`;
|
||||
const reqHeaders = stripHopByHop(c.req.raw.headers);
|
||||
|
||||
try {
|
||||
const resp = await fetch(target, { headers: reqHeaders });
|
||||
const respHeaders = stripHopByHop(resp.headers);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: respHeaders,
|
||||
});
|
||||
} catch {
|
||||
return c.text("Kratos unavailable", 502);
|
||||
}
|
||||
}
|
||||
244
server/hydra.ts
Normal file
244
server/hydra.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
const HYDRA_ADMIN_URL =
|
||||
Deno.env.get("HYDRA_ADMIN_URL") ??
|
||||
"http://hydra-admin.ory.svc.cluster.local:4445";
|
||||
|
||||
const KRATOS_ADMIN_URL =
|
||||
Deno.env.get("KRATOS_ADMIN_URL") ??
|
||||
"http://kratos-admin.ory.svc.cluster.local:80";
|
||||
|
||||
const TRUSTED_CLIENT_IDS = new Set(
|
||||
(Deno.env.get("TRUSTED_CLIENT_IDS") ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
async function hydraFetch(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const resp = await fetch(`${HYDRA_ADMIN_URL}${path}`, {
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
...init,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Kratos identity and map its traits to standard OIDC claims,
|
||||
* filtered by the granted scopes.
|
||||
*/
|
||||
async function getIdentityClaims(
|
||||
subject: string,
|
||||
grantedScopes: string[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const scopes = new Set(grantedScopes);
|
||||
const claims: Record<string, unknown> = {};
|
||||
|
||||
let traits: Record<string, unknown>;
|
||||
let verifiableAddresses: Array<{ value: string; verified: boolean }>;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KRATOS_ADMIN_URL}/admin/identities/${subject}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!resp.ok) return claims;
|
||||
const identity = await resp.json();
|
||||
traits = (identity.traits as Record<string, unknown>) ?? {};
|
||||
verifiableAddresses = identity.verifiable_addresses ?? [];
|
||||
} catch {
|
||||
return claims;
|
||||
}
|
||||
|
||||
if (scopes.has("email") && traits.email) {
|
||||
claims.email = traits.email;
|
||||
const addr = verifiableAddresses.find((a) => a.value === traits.email);
|
||||
claims.email_verified = addr?.verified ?? false;
|
||||
}
|
||||
|
||||
if (scopes.has("profile")) {
|
||||
if (traits.given_name) claims.given_name = traits.given_name;
|
||||
if (traits.family_name) claims.family_name = traits.family_name;
|
||||
if (traits.middle_name) claims.middle_name = traits.middle_name;
|
||||
if (traits.nickname) claims.nickname = traits.nickname;
|
||||
if (traits.picture) claims.picture = traits.picture;
|
||||
if (traits.phone_number) claims.phone_number = traits.phone_number;
|
||||
|
||||
// Synthesize "name" from given + family name
|
||||
const parts = [traits.given_name, traits.family_name].filter(Boolean);
|
||||
if (parts.length > 0) claims.name = parts.join(" ");
|
||||
|
||||
// Non-standard but useful: map to first_name/last_name aliases
|
||||
// that some La Suite apps read via OIDC_USERINFO_FULLNAME_FIELDS
|
||||
if (traits.given_name) claims.first_name = traits.given_name;
|
||||
if (traits.family_name) claims.last_name = traits.family_name;
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** GET /api/hydra/consent?challenge=<ch> */
|
||||
export async function getConsent(c: Context): Promise<Response> {
|
||||
const challenge = c.req.query("challenge");
|
||||
if (!challenge) return c.json({ error: "Missing challenge" }, 400);
|
||||
|
||||
try {
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/consent?consent_challenge=${challenge}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
|
||||
// Auto-accept all consent requests — all our clients are internal/trusted.
|
||||
// Hydra's skip_consent should prevent reaching here, but belt-and-suspenders.
|
||||
{
|
||||
const idTokenClaims = await getIdentityClaims(
|
||||
data.subject,
|
||||
data.requested_scope,
|
||||
);
|
||||
const acceptResp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
grant_scope: data.requested_scope,
|
||||
grant_access_token_audience:
|
||||
data.requested_access_token_audience,
|
||||
remember: true,
|
||||
remember_for: 2592000,
|
||||
session: { id_token: idTokenClaims },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const acceptData = await acceptResp.json();
|
||||
return c.json({ redirect_to: acceptData.redirect_to, auto: true });
|
||||
}
|
||||
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/hydra/consent/accept */
|
||||
export async function acceptConsent(c: Context): Promise<Response> {
|
||||
const body = await c.req.json();
|
||||
const { challenge, grantScope, remember } = body;
|
||||
if (!challenge) return c.json({ error: "Missing challenge" }, 400);
|
||||
|
||||
try {
|
||||
// Fetch the consent request to get the subject (identity ID)
|
||||
const consentResp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/consent?consent_challenge=${challenge}`,
|
||||
);
|
||||
const consentData = await consentResp.json();
|
||||
|
||||
const idTokenClaims = await getIdentityClaims(
|
||||
consentData.subject,
|
||||
grantScope ?? consentData.requested_scope,
|
||||
);
|
||||
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/consent/accept?consent_challenge=${challenge}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
grant_scope: grantScope,
|
||||
remember: remember ?? false,
|
||||
remember_for: 0,
|
||||
session: { id_token: idTokenClaims },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await resp.json();
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/hydra/consent/reject */
|
||||
export async function rejectConsent(c: Context): Promise<Response> {
|
||||
const body = await c.req.json();
|
||||
const { challenge } = body;
|
||||
if (!challenge) return c.json({ error: "Missing challenge" }, 400);
|
||||
|
||||
try {
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/consent/reject?consent_challenge=${challenge}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
error: "access_denied",
|
||||
error_description: "The resource owner denied the request",
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await resp.json();
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/hydra/logout?challenge=<ch> */
|
||||
export async function getLogout(c: Context): Promise<Response> {
|
||||
const challenge = c.req.query("challenge");
|
||||
if (!challenge) return c.json({ error: "Missing challenge" }, 400);
|
||||
|
||||
try {
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/logout?logout_challenge=${challenge}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/hydra/logout/accept */
|
||||
export async function acceptLogout(c: Context): Promise<Response> {
|
||||
const body = await c.req.json();
|
||||
const { challenge } = body;
|
||||
if (!challenge) return c.json({ error: "Missing challenge" }, 400);
|
||||
|
||||
try {
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/logout/accept?logout_challenge=${challenge}`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
const data = await resp.json();
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/hydra/login/accept — accept Hydra login challenge after Kratos login succeeds. */
|
||||
export async function acceptLogin(c: Context): Promise<Response> {
|
||||
const body = await c.req.json();
|
||||
const { challenge, subject } = body;
|
||||
if (!challenge || !subject) {
|
||||
return c.json({ error: "Missing challenge or subject" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await hydraFetch(
|
||||
`/admin/oauth2/auth/requests/login/accept?login_challenge=${challenge}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
subject,
|
||||
remember: true,
|
||||
remember_for: 0,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await resp.json();
|
||||
return c.json(data);
|
||||
} catch {
|
||||
return c.text("Hydra unavailable", 502);
|
||||
}
|
||||
}
|
||||
66
server/proxy.ts
Normal file
66
server/proxy.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
const KRATOS_ADMIN_URL =
|
||||
Deno.env.get("KRATOS_ADMIN_URL") ??
|
||||
"http://kratos-admin.ory.svc.cluster.local:80";
|
||||
|
||||
const KRATOS_PUBLIC_URL =
|
||||
Deno.env.get("KRATOS_PUBLIC_URL") ??
|
||||
"http://kratos-public.ory.svc.cluster.local:80";
|
||||
|
||||
// Paths that must be served from the public API (not the admin API)
|
||||
const PUBLIC_API_PATHS = ["/schemas"];
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export async function proxyHandler(c: Context): Promise<Response> {
|
||||
const url = new URL(c.req.url);
|
||||
// Strip the /api prefix
|
||||
const path = url.pathname.replace(/^\/api/, "") || "/";
|
||||
const isPublic = PUBLIC_API_PATHS.some((p) => path === p || path.startsWith(p + "/"));
|
||||
const upstream = isPublic ? KRATOS_PUBLIC_URL : KRATOS_ADMIN_URL;
|
||||
const target = `${upstream}${path}${url.search}`;
|
||||
|
||||
const reqHeaders = new Headers();
|
||||
for (const [key, value] of c.req.raw.headers.entries()) {
|
||||
if (!HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== "host") {
|
||||
reqHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(target, {
|
||||
method: c.req.method,
|
||||
headers: reqHeaders,
|
||||
body: c.req.method !== "GET" && c.req.method !== "HEAD"
|
||||
? c.req.raw.body
|
||||
: undefined,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch {
|
||||
return c.text("Upstream unavailable", 502);
|
||||
}
|
||||
|
||||
const respHeaders = new Headers();
|
||||
for (const [key, value] of resp.headers.entries()) {
|
||||
if (!HOP_BY_HOP.has(key.toLowerCase())) {
|
||||
respHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: respHeaders,
|
||||
});
|
||||
}
|
||||
283
server/s3.ts
Normal file
283
server/s3.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
const SEAWEEDFS_S3_URL =
|
||||
Deno.env.get("SEAWEEDFS_S3_URL") ??
|
||||
"http://seaweedfs-filer.storage.svc.cluster.local:8333";
|
||||
const ACCESS_KEY = Deno.env.get("SEAWEEDFS_ACCESS_KEY") ?? "";
|
||||
const SECRET_KEY = Deno.env.get("SEAWEEDFS_SECRET_KEY") ?? "";
|
||||
const BUCKET = "avatars";
|
||||
const REGION = "us-east-1";
|
||||
|
||||
const KRATOS_ADMIN_URL =
|
||||
Deno.env.get("KRATOS_ADMIN_URL") ??
|
||||
"http://kratos-admin.ory.svc.cluster.local:80";
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// --- AWS Signature V4 (minimal, for single-bucket S3 operations) ---
|
||||
|
||||
async function hmacSha256(
|
||||
key: ArrayBuffer | Uint8Array,
|
||||
data: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const keyBuf = key instanceof Uint8Array ? key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) : key;
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuf as ArrayBuffer,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data));
|
||||
}
|
||||
|
||||
async function sha256(data: Uint8Array): Promise<string> {
|
||||
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
||||
const hash = await crypto.subtle.digest("SHA-256", buf);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function toHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function signRequest(
|
||||
method: string,
|
||||
url: URL,
|
||||
headers: Record<string, string>,
|
||||
body: Uint8Array | null,
|
||||
): Promise<Record<string, string>> {
|
||||
const now = new Date();
|
||||
const dateStamp = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
const shortDate = dateStamp.slice(0, 8);
|
||||
const scope = `${shortDate}/${REGION}/s3/aws4_request`;
|
||||
|
||||
headers["x-amz-date"] = dateStamp;
|
||||
headers["x-amz-content-sha256"] = body
|
||||
? await sha256(body)
|
||||
: await sha256(new Uint8Array(0));
|
||||
|
||||
const signedHeaderKeys = Object.keys(headers)
|
||||
.map((k) => k.toLowerCase())
|
||||
.sort();
|
||||
const signedHeaders = signedHeaderKeys.join(";");
|
||||
|
||||
const canonicalHeaders = signedHeaderKeys
|
||||
.map((k) => `${k}:${headers[k] ?? headers[Object.keys(headers).find((h) => h.toLowerCase() === k)!]}`)
|
||||
.join("\n") + "\n";
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
url.pathname,
|
||||
url.search.replace(/^\?/, ""),
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
headers["x-amz-content-sha256"],
|
||||
].join("\n");
|
||||
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
dateStamp,
|
||||
scope,
|
||||
await sha256(encoder.encode(canonicalRequest)),
|
||||
].join("\n");
|
||||
|
||||
let signingKey: ArrayBuffer = await hmacSha256(
|
||||
encoder.encode("AWS4" + SECRET_KEY),
|
||||
shortDate,
|
||||
);
|
||||
signingKey = await hmacSha256(signingKey, REGION);
|
||||
signingKey = await hmacSha256(signingKey, "s3");
|
||||
signingKey = await hmacSha256(signingKey, "aws4_request");
|
||||
|
||||
const signature = toHex(await hmacSha256(signingKey, stringToSign));
|
||||
|
||||
headers["Authorization"] =
|
||||
`AWS4-HMAC-SHA256 Credential=${ACCESS_KEY}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function s3Request(
|
||||
method: string,
|
||||
key: string,
|
||||
body: Uint8Array | null = null,
|
||||
contentType?: string,
|
||||
): Promise<Response> {
|
||||
const url = new URL(`/${BUCKET}/${key}`, SEAWEEDFS_S3_URL);
|
||||
const headers: Record<string, string> = {
|
||||
host: url.host,
|
||||
};
|
||||
if (contentType) headers["content-type"] = contentType;
|
||||
|
||||
await signRequest(method, url, headers, body);
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT /api/avatar — upload avatar image. */
|
||||
export async function uploadAvatar(c: Context): Promise<Response> {
|
||||
const identity = c.get("identity");
|
||||
if (!identity?.id) return c.text("Unauthorized", 401);
|
||||
|
||||
const contentType = c.req.header("content-type") ?? "";
|
||||
|
||||
let imageBytes: Uint8Array;
|
||||
let imageType: string;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get("avatar");
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ error: "No avatar file provided" }, 400);
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
return c.json(
|
||||
{ error: "Invalid file type. Use JPEG, PNG, or WebP." },
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Max 2MB." }, 400);
|
||||
}
|
||||
imageBytes = new Uint8Array(await file.arrayBuffer());
|
||||
imageType = file.type;
|
||||
} else {
|
||||
// Raw body upload
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
return c.json(
|
||||
{ error: "Invalid content type. Use JPEG, PNG, or WebP." },
|
||||
400,
|
||||
);
|
||||
}
|
||||
imageBytes = new Uint8Array(await c.req.arrayBuffer());
|
||||
if (imageBytes.length > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Max 2MB." }, 400);
|
||||
}
|
||||
imageType = contentType;
|
||||
}
|
||||
|
||||
const key = identity.id;
|
||||
|
||||
try {
|
||||
const resp = await s3Request("PUT", key, imageBytes, imageType);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
return c.json({ error: `S3 upload failed: ${text}` }, 502);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Avatar upload error:", err);
|
||||
return c.json({ error: `Storage unavailable: ${err}` }, 502);
|
||||
}
|
||||
|
||||
// Update identity picture trait via Kratos Admin API
|
||||
const PUBLIC_URL = Deno.env.get("PUBLIC_URL") ?? "http://localhost:3000";
|
||||
const avatarUrl = `${PUBLIC_URL}/api/avatar/${identity.id}`;
|
||||
try {
|
||||
const getResp = await fetch(
|
||||
`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (getResp.ok) {
|
||||
const identityData = await getResp.json();
|
||||
identityData.traits.picture = avatarUrl;
|
||||
const putResp = await fetch(
|
||||
`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_id: identityData.schema_id,
|
||||
state: identityData.state,
|
||||
traits: identityData.traits,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!putResp.ok) {
|
||||
const errText = await putResp.text();
|
||||
console.error("Kratos trait update failed:", putResp.status, errText);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Kratos trait update error:", err);
|
||||
}
|
||||
|
||||
return c.json({ url: avatarUrl });
|
||||
}
|
||||
|
||||
/** GET /api/avatar/:id — proxy avatar image from SeaweedFS. */
|
||||
export async function getAvatar(c: Context): Promise<Response> {
|
||||
const id = c.req.param("id");
|
||||
if (!id) return c.text("Missing id", 400);
|
||||
|
||||
try {
|
||||
const resp = await s3Request("GET", id);
|
||||
if (resp.status === 404 || resp.status === 403) {
|
||||
return c.body(null, 404);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return c.text("Storage error", 502);
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const ct = resp.headers.get("content-type");
|
||||
if (ct) headers.set("Content-Type", ct);
|
||||
headers.set("Cache-Control", "public, max-age=300, must-revalidate");
|
||||
|
||||
return new Response(resp.body, { status: 200, headers });
|
||||
} catch {
|
||||
return c.text("Storage unavailable", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/avatar — delete current user's avatar. */
|
||||
export async function deleteAvatar(c: Context): Promise<Response> {
|
||||
const identity = c.get("identity");
|
||||
if (!identity?.id) return c.text("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
await s3Request("DELETE", identity.id);
|
||||
} catch {
|
||||
return c.text("Storage unavailable", 502);
|
||||
}
|
||||
|
||||
// Clear picture trait
|
||||
try {
|
||||
const getResp = await fetch(
|
||||
`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (getResp.ok) {
|
||||
const identityData = await getResp.json();
|
||||
delete identityData.traits.picture;
|
||||
await fetch(`${KRATOS_ADMIN_URL}/admin/identities/${identity.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_id: identityData.schema_id,
|
||||
state: identityData.state,
|
||||
traits: identityData.traits,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user