From 3cc3013d5a797c5411ec035386e7bfb2b8c7987e Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 21:01:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20OIDC=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=88F23=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 openid-client,新增 /api/auth/oidc/login + /api/auth/oidc/callback - 通过 email 绑定既有员工账号,保留密码登录 fallback - OIDC 状态存 cookie(HMAC) + PKCE + SameSite=lax 防 CSRF - 更新 env/README 与 v3 CSV 状态推进 --- deploy/trialrun/.env.example | 8 + deploy/trialrun/docker-compose.yml | 18 ++ .../2026-02-09_13-39-02-p5-blueprint-v3.csv | 2 +- server/.env.example | 8 + server/README.md | 5 + server/package-lock.json | 32 +++ server/package.json | 1 + server/src/config.js | 7 + server/src/routes/auth.js | 242 ++++++++++++++++++ server/src/services/oidc/index.js | 64 +++++ 10 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 server/src/services/oidc/index.js diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 507dab6d..7ae9ba04 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -15,6 +15,14 @@ JWT_SECRET="change_me_in_trialrun" # 生成方式示例:openssl rand -base64 32 FIELD_ENCRYPTION_KEY="" +# ===== OIDC/SSO(F23,可选)===== +# OIDC_ENABLED="true" +# OIDC_ISSUER_URL="" +# OIDC_CLIENT_ID="" +# OIDC_CLIENT_SECRET="" +# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback" +# OIDC_SCOPES="openid profile email" + # ===== 数据库(Compose 内部连接)===== POSTGRES_DB="hyb_oa" POSTGRES_USER="hyboa_user" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index feb42557..204568d5 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -64,6 +64,12 @@ services: APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} + OIDC_ENABLED: ${OIDC_ENABLED} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + OIDC_SCOPES: ${OIDC_SCOPES} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} @@ -120,6 +126,12 @@ services: APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} + OIDC_ENABLED: ${OIDC_ENABLED} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + OIDC_SCOPES: ${OIDC_SCOPES} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} @@ -164,6 +176,12 @@ services: APP_ORIGIN: ${APP_ORIGIN} JWT_SECRET: ${JWT_SECRET} FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY} + OIDC_ENABLED: ${OIDC_ENABLED} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + OIDC_SCOPES: ${OIDC_SCOPES} DATABASE_URL: ${DATABASE_URL} STORAGE_DIR: ${STORAGE_DIR} STORAGE_DRIVER: ${STORAGE_DRIVER} diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index e67eafca..bd4185c1 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -24,7 +24,7 @@ F19d,作业迁移(超期归档 -> pg-boss),"将 90 天游离归档迁移到 F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑","开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归","manual: docker compose 起两个进程并跑回归脚本",manual,DONE,DONE,DONE,"deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md","[""F19""]","仍保持单机;为未来横向扩展铺路" F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,DONE,DONE,DONE,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO" F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" -F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,TODO,TODO,TODO,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略" +F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,DONE,DONE,DONE,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略" F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单" F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略" F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 admin@hyinda.com/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer ' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/departments//archive';可选 curl -sS -H 'authorization: Bearer ' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装" diff --git a/server/.env.example b/server/.env.example index 466d9970..8f4801b6 100644 --- a/server/.env.example +++ b/server/.env.example @@ -38,6 +38,14 @@ MAX_UPLOAD_SIZE_BYTES="10485760" # WEAK_PASSWORD_CHECK_ENABLED="true" # WEAK_PASSWORD_MIN_SCORE="2" # zxcvbn score < 2 -> reject +# ===== OIDC/SSO(F23,可选)===== +# OIDC_ENABLED="true" +# OIDC_ISSUER_URL="" +# OIDC_CLIENT_ID="" +# OIDC_CLIENT_SECRET="" +# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback" +# OIDC_SCOPES="openid profile email" + DELEGATION_REVOKE_CRON="0 * * * *" OCR_PROVIDER="off" diff --git a/server/README.md b/server/README.md index bed1a3fe..4bc0fc52 100644 --- a/server/README.md +++ b/server/README.md @@ -394,6 +394,11 @@ npm run job:ocr-once - `RATE_LIMIT_LOGIN_MAX`: `/api/auth/login` 等接口限流次数(默认 `10`) - `RATE_LIMIT_LOGIN_WINDOW`: 限流时间窗(默认 `1 minute`,支持 ms 格式如 `30s`/`1m`) - `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`) +- `OIDC_ENABLED`: 是否启用 OIDC 登录(可选;默认 `false`) +- `OIDC_ISSUER_URL`: OIDC issuer URL(发现地址) +- `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`: OIDC client 配置 +- `OIDC_REDIRECT_URI`: 回调地址(例如 `http://127.0.0.1:3000/api/auth/oidc/callback`) +- `OIDC_SCOPES`: 默认 `openid profile email` - `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`) - `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`) - `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`) diff --git a/server/package-lock.json b/server/package-lock.json index 4dda72ce..28d1c414 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "fastify-plugin": "^5.1.0", "node-cron": "^4.2.1", "nodemailer": "^6.10.1", + "openid-client": "^6.8.2", "pg": "^8.18.0", "pg-boss": "^10.3.0", "prisma": "^7.3.0", @@ -4389,6 +4390,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -4908,6 +4918,15 @@ "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -4938,6 +4957,19 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", diff --git a/server/package.json b/server/package.json index a97275fe..7adbdceb 100644 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "fastify-plugin": "^5.1.0", "nodemailer": "^6.10.1", "node-cron": "^4.2.1", + "openid-client": "^6.8.2", "prom-client": "^15.1.0", "pg-boss": "^10.3.0", "pg": "^8.18.0", diff --git a/server/src/config.js b/server/src/config.js index 52fd91f4..8bfaef57 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -49,6 +49,13 @@ function getConfig() { loginFailMaxAttempts: Number(process.env.AUTH_LOGIN_FAIL_MAX_ATTEMPTS ?? 5), loginLockSeconds: Number(process.env.AUTH_LOGIN_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS), + oidcEnabled: process.env.OIDC_ENABLED === "true", + oidcIssuerUrl: process.env.OIDC_ISSUER_URL ?? "", + oidcClientId: process.env.OIDC_CLIENT_ID ?? "", + oidcClientSecret: process.env.OIDC_CLIENT_SECRET ?? "", + oidcRedirectUri: process.env.OIDC_REDIRECT_URI ?? "", + oidcScopes: process.env.OIDC_SCOPES ?? "openid profile email", + appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai", storageDir: path.resolve(serverRoot, storageDir), storageDriver: process.env.STORAGE_DRIVER ?? "local", diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c5b6c329..3f4fcffd 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,11 +1,15 @@ +const crypto = require("node:crypto"); + const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); +const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); const { createNotifications } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); +const { generators, getOidcClient } = require("../services/oidc"); function getHeaderString(request, name) { const value = request.headers[name]; @@ -35,12 +39,55 @@ function refreshCookieOptions(config) { }; } +function oidcCookieOptions(config) { + return { + httpOnly: true, + sameSite: "lax", + secure: config.cookieSecure, + path: "/api/auth/oidc", + maxAge: 10 * 60, + }; +} + +function resolvePrimaryAppOrigin(config, request) { + const configured = typeof config.appOrigin === "string" ? config.appOrigin : ""; + const first = configured + .split(",") + .map((s) => s.trim()) + .filter(Boolean)[0]; + if (first) return first; + + const host = request.headers.host ?? "127.0.0.1:3000"; + const protocol = request.protocol ?? "http"; + return `${protocol}://${host}`; +} + +function sanitizeReturnTo(value) { + if (typeof value !== "string") { + return "/"; + } + + const trimmed = value.trim(); + if (!trimmed || !trimmed.startsWith("/")) { + return "/"; + } + + // Block protocol-relative URLs. + if (trimmed.startsWith("//")) { + return "/"; + } + + return trimmed; +} + async function authRoutes(fastify) { const loginBodySchema = z.object({ identifier: z.string().min(3), password: z.string().min(1), }); + const oidcCookieName = "oidc_login"; + const authRateLimit = fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function" ? fastify.rateLimit({ @@ -450,6 +497,201 @@ async function authRoutes(fastify) { return sendData(reply, request, { accessToken }); }); + fastify.get("/oidc/login", async (request, reply) => { + if (!fastify.config.oidcEnabled) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const returnTo = sanitizeReturnTo(request.query?.returnTo); + const state = crypto.randomUUID(); + const nonce = crypto.randomUUID(); + + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + + const deviceFingerprint = + getHeaderString(request, "x-device-fingerprint") ?? `oidc:${crypto.randomUUID()}`; + + const expiresAt = Date.now() + 10 * 60 * 1000; + const token = signPayload( + { + exp: expiresAt, + state, + nonce, + codeVerifier, + returnTo, + deviceFingerprint, + }, + fastify.config.jwtSecret, + ); + + reply.setCookie(oidcCookieName, token, oidcCookieOptions(fastify.config)); + + const { client, config } = await getOidcClient(fastify.config); + const authorizationUrl = client.authorizationUrl({ + scope: config.scopes, + redirect_uri: config.redirectUri, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + return reply.redirect(authorizationUrl); + }); + + fastify.get("/oidc/callback", async (request, reply) => { + if (!fastify.config.oidcEnabled) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const cookieToken = request.cookies?.[oidcCookieName]; + const verified = verifyToken(cookieToken, fastify.config.jwtSecret); + if (!verified.ok) { + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_004", + message: "Access Token 已过期/无效", + statusCode: 401, + }); + } + + const ctx = verified.payload ?? {}; + const queryState = request.query?.state; + if ( + typeof ctx.exp !== "number" || + ctx.exp <= Date.now() || + typeof ctx.state !== "string" || + typeof ctx.nonce !== "string" || + typeof ctx.codeVerifier !== "string" || + typeof queryState !== "string" || + queryState !== ctx.state + ) { + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_004", + message: "Access Token 已过期/无效", + statusCode: 401, + }); + } + + let claims = null; + try { + const { client, config } = await getOidcClient(fastify.config); + const params = client.callbackParams(request.raw); + const tokenSet = await client.callback(config.redirectUri, params, { + state: ctx.state, + nonce: ctx.nonce, + code_verifier: ctx.codeVerifier, + }); + claims = tokenSet?.claims?.() ?? null; + } catch (error) { + fastify.log.error({ err: error, requestId: request.id }, "OIDC callback failed"); + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_004", + message: "Access Token 已过期/无效", + statusCode: 401, + }); + } + + const email = + typeof claims?.email === "string" && claims.email.trim() + ? claims.email.trim() + : typeof claims?.preferred_username === "string" && claims.preferred_username.trim() + ? claims.preferred_username.trim() + : ""; + if (!email) { + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_003", + message: "账号已离职/禁用", + statusCode: 403, + }); + } + + const employee = await fastify.prisma.employee.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true, status: true, token_version: true }, + }); + if (!employee || employee.status !== "active") { + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + throw new AppError({ + code: "AUTH_003", + message: "账号已离职/禁用", + statusCode: 403, + }); + } + + const refreshToken = randomRefreshToken(); + const refreshTokenHash = sha256Hex(refreshToken); + const now = new Date(); + const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); + + await fastify.prisma.$transaction(async (tx) => { + const activeSessions = await tx.authSession.findMany({ + where: { + employee_id: employee.id, + revoked_at: null, + expires_at: { gt: now }, + }, + orderBy: { created_at: "asc" }, + select: { id: true }, + }); + + const maxSessions = 5; + const allowedExisting = maxSessions - 1; + if (activeSessions.length > allowedExisting) { + const revokeCount = activeSessions.length - allowedExisting; + const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id); + await tx.authSession.updateMany({ + where: { id: { in: idsToRevoke } }, + data: { revoked_at: now }, + }); + } + + await tx.authSession.create({ + data: { + employee_id: employee.id, + refresh_token_hash: refreshTokenHash, + device_fingerprint: ctx.deviceFingerprint || `oidc:${crypto.randomUUID()}`, + ip_address: request.ip, + user_agent: getHeaderString(request, "user-agent") ?? "", + expires_at: expiresAt, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_OIDC_LOGIN_SUCCESS", + targetType: "employee", + targetId: employee.id, + extra: { email }, + }), + }); + }); + + reply.setCookie( + fastify.config.refreshCookieName, + refreshToken, + refreshCookieOptions(fastify.config), + ); + reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config)); + + const origin = resolvePrimaryAppOrigin(fastify.config, request); + const returnTo = sanitizeReturnTo(ctx.returnTo); + return reply.redirect(`${origin}${returnTo}`); + }); + fastify.post("/logout", async (request, reply) => { const refreshToken = request.cookies?.[fastify.config.refreshCookieName]; if (typeof refreshToken === "string" && refreshToken) { diff --git a/server/src/services/oidc/index.js b/server/src/services/oidc/index.js new file mode 100644 index 00000000..6a5d7b00 --- /dev/null +++ b/server/src/services/oidc/index.js @@ -0,0 +1,64 @@ +const { Issuer, generators } = require("openid-client"); + +function validateOidcConfig(config) { + if (!config?.oidcEnabled) { + throw new Error("OIDC is disabled"); + } + + if (!config.oidcIssuerUrl) { + throw new Error("Missing OIDC_ISSUER_URL"); + } + + if (!config.oidcClientId) { + throw new Error("Missing OIDC_CLIENT_ID"); + } + + if (!config.oidcRedirectUri) { + throw new Error("Missing OIDC_REDIRECT_URI"); + } + + return { + issuerUrl: config.oidcIssuerUrl, + clientId: config.oidcClientId, + clientSecret: config.oidcClientSecret || undefined, + redirectUri: config.oidcRedirectUri, + scopes: config.oidcScopes || "openid profile email", + }; +} + +let cachedClientPromise = null; +let cachedClientKey = null; + +async function getOidcClient(config) { + const oidc = validateOidcConfig(config); + const key = JSON.stringify({ + issuerUrl: oidc.issuerUrl, + clientId: oidc.clientId, + redirectUri: oidc.redirectUri, + }); + + if (cachedClientPromise && cachedClientKey === key) { + return cachedClientPromise; + } + + cachedClientKey = key; + cachedClientPromise = Issuer.discover(oidc.issuerUrl).then((issuer) => { + const client = new issuer.Client({ + client_id: oidc.clientId, + client_secret: oidc.clientSecret, + redirect_uris: [oidc.redirectUri], + response_types: ["code"], + }); + + return { issuer, client, config: oidc }; + }); + + return cachedClientPromise; +} + +module.exports = { + generators, + getOidcClient, + validateOidcConfig, +}; +