初回コミット: AltStore PAL向けADPホスティングSaaS
- Fastify API・Node Worker・PostgreSQL・S3互換ストレージ構成
- ADP ZIPのマルチパートアップロードとManifest検証(パストラバーサル・ZIP爆弾等を拒否)
- manifest.json/signatureは再シリアライズせず元バイト列を保持
- Cloudflare風の運用向け管理ダッシュボード(shadcn/Radix・日本語UI・4ルート)
概要/Sources/アプリ/リリース + ルートランディングページ
- 汎用OIDC SSO・Workspace単位の認可・demo mode
- 匿名配布: /sources/{slug}/source.json, /artifacts/{releaseId}/manifest.json
- Docker Compose対応
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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" });
|
||||
return reply.redirect(appConfig.WEB_ORIGIN);
|
||||
});
|
||||
|
||||
app.get("/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(sessionCookie, { path: "/" });
|
||||
return reply.redirect(appConfig.WEB_ORIGIN);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user