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:
2026-03-21 15:17:56 +00:00
commit 1467a948d0
65 changed files with 5525 additions and 0 deletions

289
server/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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 });
}