import { createHash, randomBytes } from "node:crypto"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose"; import type { Identity } from "../../../packages/core/src/index"; import type { AppConfig } from "./config"; const stateCookie = "altdock_oidc_state"; const verifierCookie = "altdock_oidc_verifier"; const nonceCookie = "altdock_oidc_nonce"; const sessionCookie = "altdock_session"; function base64Url(value: Buffer) { return value.toString("base64url"); } function pkceChallenge(verifier: string) { return createHash("sha256").update(verifier).digest("base64url"); } export async function createSession(identity: Identity, appConfig: AppConfig) { return new SignJWT({ ...identity }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("7d") .sign(new TextEncoder().encode(appConfig.SESSION_SECRET)); } export async function readSession(token: string | undefined, appConfig: AppConfig): Promise { if (!token) return null; try { const result = await jwtVerify(token, new TextEncoder().encode(appConfig.SESSION_SECRET), { algorithms: ["HS256"] }); const payload = result.payload; if (typeof payload.subject !== "string" || typeof payload.email !== "string" || typeof payload.displayName !== "string") return null; return { subject: payload.subject, email: payload.email, displayName: payload.displayName }; } catch { return null; } } export async function getIdentity(request: FastifyRequest, appConfig: AppConfig): Promise { if (appConfig.AUTH_MODE === "demo") { const email = String(request.headers["x-demo-user"] || "demo@altdock.local"); return { subject: `demo:${email}`, email, displayName: email.split("@")[0] || "Demo Developer" }; } return readSession(request.cookies[sessionCookie], appConfig); } function setSession(reply: FastifyReply, token: string) { reply.setCookie(sessionCookie, token, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 7, }); } async function oidcDiscovery(appConfig: AppConfig) { if (!appConfig.OIDC_ISSUER_URL) throw new Error("OIDC_ISSUER_URL_REQUIRED"); const response = await fetch(`${appConfig.OIDC_ISSUER_URL.replace(/\/$/, "")}/.well-known/openid-configuration`); if (!response.ok) throw new Error(`OIDC_DISCOVERY_FAILED:${response.status}`); return response.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; jwks_uri: string; issuer: string }>; } export async function registerAuthRoutes(app: FastifyInstance, appConfig: AppConfig) { app.get("/auth/login", async (_request, reply) => { if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN); if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI) throw new Error("OIDC_CONFIGURATION_REQUIRED"); const provider = await oidcDiscovery(appConfig); const state = base64Url(randomBytes(24)); const verifier = base64Url(randomBytes(32)); const nonce = base64Url(randomBytes(24)); const params = new URLSearchParams({ client_id: appConfig.OIDC_CLIENT_ID, redirect_uri: appConfig.OIDC_REDIRECT_URI, response_type: "code", scope: "openid profile email", state, nonce, code_challenge: pkceChallenge(verifier), code_challenge_method: "S256", }); reply.setCookie(stateCookie, state, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" }); reply.setCookie(verifierCookie, verifier, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" }); reply.setCookie(nonceCookie, nonce, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" }); return reply.redirect(`${provider.authorization_endpoint}?${params.toString()}`); }); app.get("/auth/callback", async (request, reply) => { if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN); if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI || !appConfig.OIDC_CLIENT_SECRET) throw new Error("OIDC_CONFIGURATION_REQUIRED"); const query = request.query as { code?: string; state?: string; error?: string }; if (query.error) return reply.code(400).send({ error: "OIDC_LOGIN_FAILED", detail: query.error }); if (!query.code || query.state !== request.cookies[stateCookie]) return reply.code(400).send({ error: "OIDC_STATE_INVALID" }); const verifier = request.cookies[verifierCookie]; const nonce = request.cookies[nonceCookie]; if (!verifier || !nonce) return reply.code(400).send({ error: "OIDC_VERIFIER_MISSING" }); const provider = await oidcDiscovery(appConfig); const tokenResponse = await fetch(provider.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: query.code, redirect_uri: appConfig.OIDC_REDIRECT_URI, client_id: appConfig.OIDC_CLIENT_ID, client_secret: appConfig.OIDC_CLIENT_SECRET, code_verifier: verifier }), }); if (!tokenResponse.ok) return reply.code(400).send({ error: "OIDC_TOKEN_EXCHANGE_FAILED" }); const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string }; if (!tokens.id_token) return reply.code(400).send({ error: "OIDC_ID_TOKEN_MISSING" }); const jwks = createRemoteJWKSet(new URL(provider.jwks_uri)); const verified = await jwtVerify(tokens.id_token, jwks, { issuer: provider.issuer, audience: appConfig.OIDC_CLIENT_ID }); const claims = verified.payload; if (claims.nonce !== nonce) return reply.code(400).send({ error: "OIDC_NONCE_INVALID" }); const subject = String(claims.sub || ""); if (!subject) return reply.code(400).send({ error: "OIDC_SUBJECT_MISSING" }); const email = String(claims.email || `${subject}@oidc.local`); const displayName = String(claims.name || claims.preferred_username || email.split("@")[0] || "Developer"); setSession(reply, await createSession({ subject, email, displayName }, appConfig)); reply.clearCookie(stateCookie, { path: "/auth" }); reply.clearCookie(verifierCookie, { path: "/auth" }); reply.clearCookie(nonceCookie, { path: "/auth" }); // SSO成功後はランディングではなく管理ダッシュボードへ直接向かう。 return reply.redirect(`${appConfig.WEB_ORIGIN.replace(/\/$/, "")}/dashboard`); }); app.get("/auth/logout", async (_request, reply) => { reply.clearCookie(sessionCookie, { path: "/" }); return reply.redirect(appConfig.WEB_ORIGIN); }); }