- コールバック成功時のリダイレクト先をWEB_ORIGINルートから /dashboardに変更(ランディングで止まらないように) - ルートページでログイン済みならダッシュボードへ自動遷移 - サイドバーのユーザーカードをOIDC identityの表示名・メールに変更し、 未ログイン時のみSSOログインリンクを表示 - demoヘッダ(x-demo-user)はdemoモード時のみ送信
128 lines
6.6 KiB
TypeScript
128 lines
6.6 KiB
TypeScript
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<Identity | null> {
|
|
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<Identity | null> {
|
|
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);
|
|
});
|
|
}
|