From b657f4a8f50b7c036b41a7998a84810084ea5cd7 Mon Sep 17 00:00:00 2001 From: Huiting Chen Date: Fri, 18 Sep 2026 00:59:57 +0800 Subject: [PATCH 1/4] feat(auth): add provider-neutral OpenID Connect sign-in --- backend/internal/oidc.js | 157 +++++++++++ backend/lib/oidc.js | 112 ++++++++ backend/migrations/20260918000000_oidc.js | 19 ++ backend/package.json | 6 +- backend/routes/main.js | 2 + backend/routes/oidc.js | 168 +++++++++++ backend/test/oidc.test.js | 326 ++++++++++++++++++++++ backend/yarn.lock | 28 ++ docs/src/guide/oidc.md | 41 +++ frontend/src/api/backend/oidc.ts | 36 +++ frontend/src/components/SiteHeader.tsx | 5 + frontend/src/context/AuthContext.tsx | 14 + frontend/src/locale/src/en.json | 69 +++++ frontend/src/locale/src/et.json | 69 +++++ frontend/src/locale/src/zh.json | 69 +++++ frontend/src/modals/OIDCLinkModal.tsx | 92 ++++++ frontend/src/pages/Dashboard/index.tsx | 6 + frontend/src/pages/Login/index.tsx | 62 +++- frontend/src/pages/Settings/Layout.tsx | 30 +- frontend/src/pages/Settings/OIDC.test.tsx | 51 ++++ frontend/src/pages/Settings/OIDC.tsx | 148 ++++++++++ 21 files changed, 1505 insertions(+), 5 deletions(-) create mode 100644 backend/internal/oidc.js create mode 100644 backend/lib/oidc.js create mode 100644 backend/migrations/20260918000000_oidc.js create mode 100644 backend/routes/oidc.js create mode 100644 backend/test/oidc.test.js create mode 100644 docs/src/guide/oidc.md create mode 100644 frontend/src/api/backend/oidc.ts create mode 100644 frontend/src/modals/OIDCLinkModal.tsx create mode 100644 frontend/src/pages/Settings/OIDC.test.tsx create mode 100644 frontend/src/pages/Settings/OIDC.tsx diff --git a/backend/internal/oidc.js b/backend/internal/oidc.js new file mode 100644 index 0000000000..1bc3f5445b --- /dev/null +++ b/backend/internal/oidc.js @@ -0,0 +1,157 @@ +import db from "../db.js"; +import { getPrivateKey } from "../lib/config.js"; +import { seal, unseal, validateConfig, discover, random, identityKey } from "../lib/oidc.js"; +import errs from "../lib/error.js"; +import userModel from "../models/user.js"; +import authModel from "../models/auth.js"; +import twoFactor from "./2fa.js"; +import TokenModel from "../models/token.js"; +import internalToken from "./token.js"; +import crypto from "node:crypto"; +const authStamp = (auth) => + crypto + .createHash("sha256") + .update( + JSON.stringify([ + auth.secret, + auth.meta?.password_changed_at, + auth.meta?.totp_enabled, + auth.meta?.totp_secret, + auth.meta?.totp_enabled_at, + ]), + ) + .digest("hex"); + +const defaults = { + enabled: false, + auto_login: false, + issuer: "", + public_url: "", + client_id: "", + client_secret: "", + scopes: "openid", + token_auth_method: "auto", +}; + +export async function readConfig() { + const row = await db()("oidc_config").where({ id: 1 }).first(); + if (!row) return { config: { ...defaults }, revision: "initial" }; + const config = { ...defaults, ...JSON.parse(row.config) }; + config.client_secret = config.client_secret ? unseal(config.client_secret, getPrivateKey()) : ""; + return { config, revision: row.revision }; +} +export function publicConfig({ config, revision }) { + const { client_secret, ...safe } = config; + return { + ...safe, + revision, + client_secret_configured: !!client_secret, + callback_url: config.public_url ? new URL("/api/oidc/callback", config.public_url).href : "", + }; +} +export async function saveConfig(access, input) { + await access.can("settings:update", "oidc"); + const previous = await readConfig(); + if (input.revision !== previous.revision) throw new errs.ValidationError("OIDC settings changed; reload and retry"); + const config = { ...previous.config }; + for (const key of ["issuer", "client_id", "public_url", "scopes"]) { + if (typeof input[key] !== "string" || input[key].length > 2048) + throw new errs.ValidationError("Invalid OIDC field"); + config[key] = input[key].trim(); + } + config.scopes = config.scopes.split(/\s+/).filter(Boolean).join(" "); + if (input.token_auth_method !== undefined) config.token_auth_method = input.token_auth_method; + for (const key of ["enabled", "auto_login"]) { + if (typeof input[key] !== "boolean") throw new errs.ValidationError("Invalid OIDC switch"); + config[key] = input[key]; + } + if (typeof input.client_secret !== "string" || input.client_secret.length > 8192) + throw new errs.ValidationError("Invalid client secret"); + if (input.client_secret) config.client_secret = input.client_secret; + try { + validateConfig(config); + } catch (error) { + throw new errs.ValidationError(error.message); + } + const revision = random(); + if (config.enabled) { + try { + await discover(config, revision); + } catch { + throw new errs.ValidationError("Unable to discover OIDC provider over HTTPS"); + } + } + const stored = { + ...config, + client_secret: config.client_secret ? seal(config.client_secret, getPrivateKey()) : "", + }; + await db().transaction(async (trx) => { + const row = await trx("oidc_config").where({ id: 1 }).first(); + if ((row?.revision || "initial") !== previous.revision) + throw new errs.ValidationError("OIDC settings changed; reload and retry"); + if (row) { + const n = await trx("oidc_config") + .where({ id: 1, revision: previous.revision }) + .update({ config: JSON.stringify(stored), revision }); + if (n !== 1) throw new errs.ValidationError("OIDC settings changed; retry"); + } else await trx("oidc_config").insert({ id: 1, config: JSON.stringify(stored), revision }); + }); + return publicConfig({ config, revision }); +} +export async function activeUser(id) { + const user = await userModel.query().where({ id, is_deleted: 0, is_disabled: 0 }).first(); + if (!user) throw new errs.AuthError("Account is unavailable"); + return user; +} +export async function reauthenticate(id, password, code) { + await activeUser(id); + const auth = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); + if (typeof password !== "string" || password.length > 1024 || !auth || !(await auth.verifyPassword(password))) + throw new errs.AuthError("Invalid current password"); + if (auth.meta?.totp_enabled) { + if (typeof code !== "string" || code.length > 32 || !(await twoFactor.verifyForLogin(id, code))) + throw new errs.AuthError("Invalid verification code"); + } + const latest = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); + if (!latest || authStamp(latest) !== authStamp(auth)) throw new errs.AuthError("Authentication changed; try again"); + return authStamp(auth); +} +export async function linkIdentity(id, issuer, subject, stamp) { + await activeUser(id); + await db().transaction(async (trx) => { + const auth = await authModel + .query(trx) + .where({ user_id: id, type: "password", is_deleted: 0 }) + .forUpdate() + .first(); + if (!auth || authStamp(auth) !== stamp) + throw new errs.AuthError("Local authentication changed; start linking again"); + const user = await userModel.query(trx).where({ id, is_deleted: 0, is_disabled: 0 }).first(); + if (!user) throw new errs.AuthError("Account is unavailable"); + const current = await trx("oidc_identity").where({ user_id: id }).first(); + if (current) throw new errs.ValidationError("An OIDC identity is already linked"); + await trx("oidc_identity").insert({ user_id: id, identity_key: identityKey(issuer, subject), issuer, subject }); + }); +} +export async function linkedUser(issuer, subject) { + const row = await db()("oidc_identity") + .where({ identity_key: identityKey(issuer, subject) }) + .first(); + if (!row || row.issuer !== issuer || row.subject !== subject) + throw new errs.AuthError("Identity is not linked to an NPM account"); + return activeUser(row.user_id); +} +export async function loginResult(user) { + await activeUser(user.id); + if (await twoFactor.isEnabled(user.id)) { + const signed = await TokenModel().create({ + iss: "api", + attrs: { id: user.id }, + scope: ["2fa-challenge"], + expiresIn: "5m", + }); + return { requires_2fa: true, challenge_token: signed.token }; + } + const result = await internalToken.getTokenFromUser(user); + return { token: result.token, expires: result.expires }; +} diff --git a/backend/lib/oidc.js b/backend/lib/oidc.js new file mode 100644 index 0000000000..ba950e08be --- /dev/null +++ b/backend/lib/oidc.js @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import * as client from "openid-client"; +import { LRUCache } from "lru-cache"; + +export const random = () => crypto.randomBytes(32).toString("base64url"); +export const identityKey = (issuer, subject) => + crypto + .createHash("sha256") + .update(JSON.stringify([issuer, subject])) + .digest("hex"); + +export function validateConfig(value) { + if (!["auto", "client_secret_basic", "client_secret_post"].includes(value.token_auth_method || "auto")) + throw new Error("Invalid token authentication method"); + if (typeof value.enabled !== "boolean" || typeof value.auto_login !== "boolean") + throw new Error("Invalid OIDC switches"); + for (const key of ["issuer", "public_url"]) { + if (!value.enabled && value[key] === "") continue; + const url = new URL(value[key]); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) + throw new Error("OIDC URLs must use HTTPS without credentials, query or fragment"); + if (key === "public_url" && url.pathname !== "/") throw new Error("Public URL must be an HTTPS origin"); + } + if (!value.enabled) return; + if (!value.client_id || !value.client_secret) throw new Error("Client ID and secret are required"); + if (!value.scopes.split(/\s+/).includes("openid")) throw new Error("Scopes must include openid"); +} + +// Secrets are encrypted using a key derived from NPM's existing persistent key. +export function seal(value, privateKey) { + const key = crypto.createHash("sha256").update(privateKey).digest(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted].map((v) => v.toString("base64url")).join("."); +} +export function unseal(value, privateKey) { + const [iv, tag, encrypted] = value.split(".").map((v) => Buffer.from(v, "base64url")); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + crypto.createHash("sha256").update(privateKey).digest(), + iv, + ); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); +} + +export class OneTimeStore { + constructor(ttl = 300000) { + this.cache = new LRUCache({ max: 1000, ttl }); + } + put(value) { + const key = random(); + this.cache.set(key, value); + return key; + } + take(key) { + const value = this.cache.get(key); + this.cache.delete(key); + return value; + } +} + +const discoveries = new LRUCache({ max: 8, ttl: 300000 }); +export const clientAuthentication = + (secret, method = "auto") => + (metadata, info, body, headers) => { + const methods = metadata.token_endpoint_auth_methods_supported || ["client_secret_basic"]; + if (method === "client_secret_basic" || (method === "auto" && methods.includes("client_secret_basic"))) + return client.ClientSecretBasic(secret)(metadata, info, body, headers); + if (method === "client_secret_post" || (method === "auto" && methods.includes("client_secret_post"))) + return client.ClientSecretPost(secret)(metadata, info, body, headers); + throw new Error("Provider must support client_secret_basic or client_secret_post"); + }; +export async function discover(config, revision) { + let pending = discoveries.get(revision); + if (!pending) { + pending = client + .discovery( + new URL(config.issuer), + config.client_id, + { client_secret: config.client_secret }, + clientAuthentication(config.client_secret, config.token_auth_method), + { timeout: 15, execute: [client.enableNonRepudiationChecks] }, + ) + .then((result) => { + const metadata = result.serverMetadata(); + for (const value of [metadata.authorization_endpoint, metadata.token_endpoint, metadata.jwks_uri]) { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password) + throw new Error("Insecure OIDC endpoint"); + } + return result; + }) + .catch((error) => { + discoveries.delete(revision); + throw error; + }); + discoveries.set(revision, pending); + } + return pending; +} + +export function sameOrigin(req, config) { + return ( + req.get("Origin") === new URL(config.public_url).origin && + (!req.get("Sec-Fetch-Site") || req.get("Sec-Fetch-Site") === "same-origin") && + req.is("application/json") + ); +} + +export { client }; diff --git a/backend/migrations/20260918000000_oidc.js b/backend/migrations/20260918000000_oidc.js new file mode 100644 index 0000000000..80d6b10932 --- /dev/null +++ b/backend/migrations/20260918000000_oidc.js @@ -0,0 +1,19 @@ +export async function up(knex) { + await knex.schema.createTable("oidc_config", (table) => { + table.integer("id").primary(); + table.text("config").notNullable(); + table.string("revision", 64).notNullable(); + }); + await knex.schema.createTable("oidc_identity", (table) => { + table.increments("id").primary(); + table.integer("user_id").unsigned().notNullable().unique(); + table.string("identity_key", 64).notNullable().unique(); + table.text("issuer").notNullable(); + table.text("subject").notNullable(); + }); +} + +export async function down(knex) { + await knex.schema.dropTable("oidc_identity"); + await knex.schema.dropTable("oidc_config"); +} diff --git a/backend/package.json b/backend/package.json index 0a73c6805a..9706c89beb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "lint": "biome lint", "prettier": "biome format --write .", "validate-schema": "node validate-schema.js", - "regenerate-config": "node scripts/regenerate-config" + "regenerate-config": "node scripts/regenerate-config", + "test:oidc": "node --experimental-test-module-mocks --test test/oidc.test.js" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^16.0.2", @@ -22,6 +23,7 @@ "body-parser": "^2.3.0", "chalk": "5.6.2", "compression": "^1.8.2", + "cookie": "^1", "express": "^5.2.1", "express-fileupload": "^1.5.2", "gravatar": "^1.8.2", @@ -29,10 +31,12 @@ "knex": "3.3.0", "liquidjs": "10.29.0", "lodash": "^4.18.1", + "lru-cache": "^11", "moment": "^2.30.1", "mysql2": "^3.24.4", "node-rsa": "^2.0.0", "objection": "3.1.5", + "openid-client": "^6", "otplib": "^13.5.0", "path": "^0.12.7", "pg": "^8.23.0", diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..648f2ef2c0 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -16,6 +16,7 @@ import reportsRoutes from "./reports.js"; import schemaRoutes from "./schema.js"; import settingsRoutes from "./settings.js"; import tokensRoutes from "./tokens.js"; +import oidcRoutes from "./oidc.js"; import usersRoutes from "./users.js"; import versionRoutes from "./version.js"; @@ -48,6 +49,7 @@ router.get("/", async (_, res /*, next*/) => { router.use("/schema", schemaRoutes); router.use("/tokens", tokensRoutes); +router.use("/oidc", oidcRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); router.use("/reports", reportsRoutes); diff --git a/backend/routes/oidc.js b/backend/routes/oidc.js new file mode 100644 index 0000000000..73fa52232b --- /dev/null +++ b/backend/routes/oidc.js @@ -0,0 +1,168 @@ +import express from "express"; +import { parse, serialize } from "cookie"; +import { client, discover, OneTimeStore, sameOrigin } from "../lib/oidc.js"; +import * as service from "../internal/oidc.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import db from "../db.js"; + +const router = express.Router(); +const transactions = new OneTimeStore(); +const handoffs = new OneTimeStore(60000); +const txCookie = "__Host-npm_oidc_tx"; +const handoffCookie = "__Host-npm_oidc_handoff"; +const cookieOptions = { path: "/", httpOnly: true, secure: true, sameSite: "lax" }; +const putCookie = (res, name, value, maxAge) => + res.append("Set-Cookie", serialize(name, value, { ...cookieOptions, maxAge })); +const clearCookie = (res, name) => putCookie(res, name, "", 0); +const handler = (fn) => async (req, res, _next) => { + try { + await fn(req, res); + } catch { + res.status(400).json({ error: { message: "OIDC request failed. Check settings or use local login." } }); + } +}; +const requireUser = async (res) => { + const id = res.locals.access.token.getUserId(); + await res.locals.access.can("users:get", id); + return id; +}; + +// Do not inherit the API's permissive credentialed CORS for cookie endpoints. +router.use((_req, res, next) => { + res.removeHeader("Access-Control-Allow-Origin"); + res.removeHeader("Access-Control-Allow-Credentials"); + res.set({ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }); + next(); +}); +router.get( + "/status", + handler(async (_req, res) => { + const { config } = await service.readConfig(); + res.json({ enabled: config.enabled, auto_login: config.auto_login }); + }), +); +router.get( + "/settings", + jwtdecode(), + handler(async (_req, res) => { + await res.locals.access.can("settings:get", "oidc"); + res.json(service.publicConfig(await service.readConfig())); + }), +); +router.put("/settings", jwtdecode(), async (req, res, next) => { + try { + res.json(await service.saveConfig(res.locals.access, req.body)); + } catch (error) { + next(error); + } +}); +router.get( + "/identity", + jwtdecode(), + handler(async (_req, res) => { + const id = await requireUser(res); + await service.activeUser(id); + const row = await db()("oidc_identity").where({ user_id: id }).first(); + res.json({ linked: !!row, issuer: row?.issuer || "" }); + }), +); +router.post( + "/unlink", + jwtdecode(), + handler(async (req, res) => { + const { config } = await service.readConfig(); + if (!sameOrigin(req, config)) return res.sendStatus(403); + const id = await requireUser(res); + await service.reauthenticate(id, req.body.password, req.body.code); + await db()("oidc_identity").where({ user_id: id }).delete(); + res.json({ linked: false }); + }), +); +async function start(req, res, userId = null, stamp = null) { + const { config, revision } = await service.readConfig(); + if (!config.enabled) return res.sendStatus(404); + if (!sameOrigin(req, config)) return res.sendStatus(403); + const provider = await discover(config, revision); + const state = client.randomState(); + const nonce = client.randomNonce(); + const verifier = client.randomPKCECodeVerifier(); + const tx = transactions.put({ state, nonce, verifier, revision, userId, stamp }); + putCookie(res, txCookie, tx, 300); + const url = client.buildAuthorizationUrl(provider, { + redirect_uri: new URL("/api/oidc/callback", config.public_url).href, + scope: config.scopes, + state, + nonce, + code_challenge: await client.calculatePKCECodeChallenge(verifier), + code_challenge_method: "S256", + }); + res.json({ url: url.href }); +} +router.post( + "/start", + handler((req, res) => start(req, res)), +); +router.post( + "/link", + jwtdecode(), + handler(async (req, res) => { + const { config } = await service.readConfig(); + if (!sameOrigin(req, config)) return res.sendStatus(403); + const id = await requireUser(res); + const stamp = await service.reauthenticate(id, req.body.password, req.body.code); + return start(req, res, id, stamp); + }), +); +router.get("/callback", async (req, res) => { + try { + const tx = transactions.take(parse(req.headers.cookie || "")[txCookie]); + clearCookie(res, txCookie); + const { config, revision } = await service.readConfig(); + if (!tx || !config.enabled || tx.revision !== revision) throw new Error("Invalid transaction"); + const provider = await discover(config, revision); + const url = new URL("/api/oidc/callback", config.public_url); + url.search = req.originalUrl.split("?")[1] || ""; + const tokens = await client.authorizationCodeGrant(provider, url, { + pkceCodeVerifier: tx.verifier, + expectedState: tx.state, + expectedNonce: tx.nonce, + idTokenExpected: true, + }); + const claims = tokens.claims(); + if (!claims?.sub) throw new Error("Missing subject"); + const current = await service.readConfig(); + if (current.revision !== revision || !current.config.enabled) throw new Error("Configuration changed"); + if (tx.userId) { + await service.linkIdentity(tx.userId, claims.iss, claims.sub, tx.stamp); + return res.redirect(303, new URL("/?oidc=linked", config.public_url).href); + } + const user = await service.linkedUser(claims.iss, claims.sub); + putCookie( + res, + handoffCookie, + handoffs.put({ userId: user.id, issuer: claims.iss, subject: claims.sub, revision }), + 60, + ); + res.redirect(303, new URL("/?oidc=complete", config.public_url).href); + } catch { + res.status(401) + .type("html") + .send( + '

OIDC login failed. Sign in locally to link your account or check the provider configuration.

Local login', + ); + } +}); +router.post( + "/exchange", + handler(async (req, res) => { + const { config, revision } = await service.readConfig(); + if (!sameOrigin(req, config)) return res.sendStatus(403); + const handoff = handoffs.take(parse(req.headers.cookie || "")[handoffCookie]); + clearCookie(res, handoffCookie); + if (!handoff || !config.enabled || handoff.revision !== revision) return res.sendStatus(401); + const user = await service.linkedUser(handoff.issuer, handoff.subject); + if (user.id !== handoff.userId) return res.sendStatus(401); + res.json(await service.loginResult(user)); + }), +); +export default router; diff --git a/backend/test/oidc.test.js b/backend/test/oidc.test.js new file mode 100644 index 0000000000..9800b7f437 --- /dev/null +++ b/backend/test/oidc.test.js @@ -0,0 +1,326 @@ +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import knex from "knex"; +import express from "express"; +import { generateKeyPair, exportJWK, SignJWT } from "jose"; + +const keys = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const database = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true }); +mock.module("../db.js", { defaultExport: () => database }); +mock.module("../lib/config.js", { + namedExports: { + isCI: () => true, + isDebugMode: () => false, + isSqlite: () => true, + isMysql: () => false, + isPostgres: () => false, + configHas: () => true, + configGet: () => ({}), + getPrivateKey: () => keys.privateKey, + getPublicKey: () => keys.publicKey, + useLetsencryptStaging: () => false, + useLetsencryptServer: () => null, + }, +}); +const { default: router } = await import("../routes/oidc.js"); +const { default: jwtMiddleware } = await import("../lib/express/jwt.js"); +const { default: Token } = await import("../models/token.js"); +const service = await import("../internal/oidc.js"); +const { default: Auth } = await import("../models/auth.js"); +const { default: User } = await import("../models/user.js"); +const { up, down } = await import("../migrations/20260918000000_oidc.js"); +const { validateConfig, seal, unseal, OneTimeStore, clientAuthentication } = await import("../lib/oidc.js"); + +test("OIDC integration with standard-only signed tokens and existing NPM accounts", async (t) => { + await database.schema.createTable("user", (table) => { + table.increments("id"); + table.string("email"); + table.string("name"); + table.string("nickname"); + table.text("roles"); + table.integer("is_deleted").defaultTo(0); + table.integer("is_disabled").defaultTo(0); + table.dateTime("created_on"); + table.dateTime("modified_on"); + }); + await database.schema.createTable("auth", (table) => { + table.increments("id"); + table.integer("user_id"); + table.string("type"); + table.text("secret"); + table.text("meta"); + table.integer("is_deleted").defaultTo(0); + table.dateTime("created_on"); + table.dateTime("modified_on"); + }); + await database.schema.createTable("user_permission", (table) => { + table.increments("id"); + table.integer("user_id"); + table.string("visibility"); + table.string("proxy_hosts"); + }); + await up(database); + await User.query().insert({ id: 1, email: "admin@example.com", name: "Admin", roles: ["admin"] }); + await User.query().insert({ id: 2, email: "user@example.com", name: "User", roles: [] }); + await database("user_permission").insert([ + { user_id: 1, visibility: "all", proxy_hosts: "manage" }, + { user_id: 2, visibility: "user", proxy_hosts: "view" }, + ]); + await Auth.query().insert({ user_id: 1, type: "password", secret: "current-password", meta: {} }); + await Auth.query().insert({ user_id: 2, type: "password", secret: "current-password", meta: {} }); + const admin = (await Token().create({ attrs: { id: 1 }, scope: ["user"], expiresIn: "1h" })).token; + const regular = (await Token().create({ attrs: { id: 2 }, scope: ["user"], expiresIn: "1h" })).token; + const challenge = (await Token().create({ attrs: { id: 1 }, scope: ["2fa-challenge"], expiresIn: "5m" })).token; + const app = express(); + app.use(express.json()); + app.use(jwtMiddleware()); + app.use("/api/oidc", router); + app.use((e, _req, res, _next) => res.status(e.status || 500).json({ error: e.message })); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const origin = "https://npm.example"; + const signing = await generateKeyPair("RS256"); + const wrongSigning = await generateKeyPair("RS256"); + const jwk = await exportJWK(signing.publicKey); + jwk.kid = "provider-key"; + let nonce = ""; + let pkce = ""; + let variant = ""; + let subject = "subject-1"; + let discoveryCount = 0; + const nativeFetch = globalThis.fetch; + globalThis.fetch = async (input, options = {}) => { + const url = String(input); + if (!url.startsWith("https://id.example")) return nativeFetch(input, options); + if (url.includes(".well-known")) { + discoveryCount++; + return Response.json({ + issuer: "https://id.example", + authorization_endpoint: "https://id.example/authorize", + token_endpoint: "https://id.example/token", + jwks_uri: "https://id.example/keys", + token_endpoint_auth_methods_supported: ["client_secret_basic"], + id_token_signing_alg_values_supported: ["RS256"], + }); + } + if (url.endsWith("/keys")) return Response.json({ keys: [jwk] }); + if (url.endsWith("/token")) { + const body = new URLSearchParams(options.body); + const headers = new Headers(options.headers); + assert.ok(headers.get("authorization")?.startsWith("Basic ")); + assert.equal(crypto.createHash("sha256").update(body.get("code_verifier")).digest("base64url"), pkce); + const jwt = await new SignJWT({ nonce: variant === "nonce" ? "wrong" : nonce }) + .setProtectedHeader({ alg: "RS256", kid: "provider-key" }) + .setIssuer(variant === "issuer" ? "https://wrong.example" : "https://id.example") + .setSubject(subject) + .setAudience(variant === "audience" ? "wrong-client" : "test-client") + .setIssuedAt() + .setExpirationTime(variant === "expired" ? "-1h" : "1h") + .sign(variant === "signature" ? wrongSigning.privateKey : signing.privateKey); + return Response.json({ access_token: "not-retained", token_type: "Bearer", id_token: jwt }); + } + throw new Error("Unexpected endpoint"); + }; + t.after(async () => { + globalThis.fetch = nativeFetch; + await new Promise((resolve) => server.close(resolve)); + await database.destroy(); + }); + async function req(path, { token, body, cookie, method = body ? "POST" : "GET", requestOrigin = origin } = {}) { + const headers = { + "Content-Type": "application/json", + Origin: requestOrigin, + "Sec-Fetch-Site": "same-origin", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }; + return nativeFetch(`${base}/api/oidc/${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + redirect: "manual", + }); + } + let config = { + enabled: true, + auto_login: true, + issuer: "https://id.example", + public_url: origin, + client_id: "test-client", + client_secret: "test-secret", + scopes: "openid", + revision: "initial", + }; + await t.test("admin-only settings, encrypted secret and optimistic concurrency", async () => { + assert.notEqual((await req("settings", { token: regular })).status, 200); + let r = await req("settings", { token: admin, method: "PUT", body: config }); + assert.equal(r.status, 200); + const v = await r.json(); + assert.equal(v.client_secret, undefined); + assert.equal(v.client_secret_configured, true); + config = { ...config, revision: v.revision }; + assert.ok(!(await database("oidc_config").first()).config.includes("test-secret")); + r = await req("settings", { token: admin, method: "PUT", body: { ...config, revision: "stale" } }); + assert.equal(r.status, 400); + r = await req("settings", { + token: admin, + method: "PUT", + body: { ...config, enabled: false, public_url: "invalid" }, + }); + assert.equal(r.status, 400); + assert.equal((await service.readConfig()).config.public_url, origin); + }); + async function start(token) { + const r = await req(token ? "link" : "start", { token, body: token ? { password: "current-password" } : {} }); + assert.equal(r.status, 200); + const url = new URL((await r.json()).url); + nonce = url.searchParams.get("nonce"); + pkce = url.searchParams.get("code_challenge"); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + return { state: url.searchParams.get("state"), cookie: r.headers.getSetCookie()[0].split(";")[0] }; + } + const callback = (tx) => req(`callback?code=code&state=${tx.state}`, { cookie: tx.cookie }); + await t.test("unknown identity rejected, linking preserves user and roles", async () => { + let tx = await start(); + assert.equal((await callback(tx)).status, 401); + tx = await start(admin); + const r = await callback(tx); + assert.equal(r.status, 303); + assert.equal(r.headers.get("Location"), `${origin}/?oidc=linked`); + assert.equal((await database("user").where({ id: 1 }).first()).roles, '["admin"]'); + assert.equal( + await database("user") + .count("id as count") + .first() + .then((v) => v.count), + 2, + ); + assert.equal((await callback(tx)).status, 401); + }); + await t.test("signed standard-only ID token to one-time NPM token handoff", async () => { + const tx = await start(); + const r = await callback(tx); + assert.equal(r.status, 303); + assert.equal(r.headers.get("Location"), `${origin}/?oidc=complete`); + const header = r.headers.getSetCookie().find((s) => s.startsWith("__Host-npm_oidc_handoff=")); + assert.ok(header.includes("Secure") && header.includes("HttpOnly") && header.includes("SameSite=Lax")); + const cookie = header.split(";")[0]; + const denied = await req("exchange", { body: {}, cookie, requestOrigin: "https://evil.example" }); + assert.equal(denied.status, 403); + assert.equal(denied.headers.get("Access-Control-Allow-Origin"), null); + const ok = await req("exchange", { body: {}, cookie }); + assert.equal(ok.status, 200); + const v = await ok.json(); + const token = await Token().load(v.token); + assert.equal(token.attrs.id, 1); + assert.deepEqual(token.scope, ["user"]); + assert.equal((await req("exchange", { body: {}, cookie })).status, 401); + }); + for (const invalid of ["state", "nonce", "issuer", "audience", "signature", "expired"]) { + await t.test(`reject invalid ${invalid}`, async () => { + variant = invalid; + const tx = await start(); + if (invalid === "state") tx.state = "wrong"; + assert.equal((await callback(tx)).status, 401); + variant = ""; + }); + } + await t.test("reject cross-browser state and challenge-token linking", async () => { + const tx = await start(); + assert.equal((await callback({ ...tx, cookie: "" })).status, 401); + assert.notEqual((await req("identity", { token: challenge })).status, 200); + assert.equal((await req("start", { body: {}, requestOrigin: "https://evil.example" })).status, 403); + }); + await t.test("disabled users and unlinked handoffs fail closed", async () => { + const tx = await start(); + const r = await callback(tx); + const cookie = r.headers + .getSetCookie() + .find((s) => s.startsWith("__Host-npm_oidc_handoff=")) + .split(";")[0]; + await database("user").where({ id: 1 }).update({ is_disabled: 1 }); + assert.notEqual((await req("exchange", { cookie, body: {} })).status, 200); + await database("user").where({ id: 1 }).update({ is_disabled: 0 }); + }); + await t.test("pending linking invalidated by password reset", async () => { + subject = "subject-2"; + const tx = await start(regular); + await Auth.query().where({ user_id: 2, type: "password" }).patch({ secret: "new-password" }); + assert.equal((await callback(tx)).status, 401); + assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); + subject = "subject-1"; + }); + await t.test("password reset during password verification cannot mint a fresh link stamp", async () => { + const original = Auth.prototype.verifyPassword; + let began; + let release; + const started = new Promise((r) => { + began = r; + }); + const barrier = new Promise((r) => { + release = r; + }); + Auth.prototype.verifyPassword = async () => { + began(); + await barrier; + return true; + }; + try { + const result = service.reauthenticate(2, "new-password").then( + () => false, + () => true, + ); + await started; + await Auth.query().where({ user_id: 2, type: "password" }).patch({ secret: "reset-during-verification" }); + release(); + assert.equal(await result, true); + } finally { + Auth.prototype.verifyPassword = original; + } + }); + await t.test("NPM two-factor remains required after OIDC", async () => { + const auth = await Auth.query().where({ user_id: 1, type: "password" }).first(); + await Auth.query() + .where({ id: auth.id }) + .patch({ meta: { totp_enabled: true } }); + const result = await service.loginResult(await service.activeUser(1)); + assert.equal(result.requires_2fa, true); + assert.equal(result.token, undefined); + }); + assert.ok(discoveryCount < 4); + await down(database); + assert.equal(await database.schema.hasTable("oidc_identity"), false); +}); + +test("configuration, encryption and one-time store", () => { + assert.throws(() => validateConfig({ enabled: false, auto_login: false, issuer: "", public_url: "bad" })); + const value = seal("secret", keys.privateKey); + assert.equal(unseal(value, keys.privateKey), "secret"); + assert.throws(() => unseal(value, keys.publicKey)); + const store = new OneTimeStore(); + const key = store.put({ ok: true }); + assert.deepEqual(store.take(key), { ok: true }); + assert.equal(store.take(key), undefined); + for (const method of ["client_secret_basic", "client_secret_post"]) { + const body = new URLSearchParams(); + const headers = new Headers(); + clientAuthentication("secret", method)( + { token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"] }, + { client_id: "client" }, + body, + headers, + ); + if (method === "client_secret_basic") assert.ok(headers.get("authorization")?.startsWith("Basic ")); + else { + assert.equal(headers.get("authorization"), null); + assert.equal(body.get("client_secret"), "secret"); + } + } +}); diff --git a/backend/yarn.lock b/backend/yarn.lock index 6e2ab5dbcf..dc43259fae 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -607,6 +607,11 @@ cookie@^0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookie@^1: + version "1.1.1" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha1-O7m9/II2nbnC9pyTycPOsxDIizw= + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -1225,6 +1230,11 @@ isexe@^4.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca" integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw== +jose@^6.2.12: + version "6.2.12" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz#65663e146edd010b98ece83f81737d3aa95fd0d7" + integrity sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc= + js-yaml@^4.2.0: version "4.3.2" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" @@ -1386,6 +1396,11 @@ long@^5.3.2: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== +lru-cache@^11: + version "11.5.2" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha1-AOFmZckMYg+6FKPDaHMql2ST92A= + lru-cache@^7.14.1: version "7.18.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" @@ -1587,6 +1602,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +oauth4webapi@^3.8.8: + version "3.8.8" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/oauth4webapi/-/oauth4webapi-3.8.8.tgz#05b47975cb621cdfd5dad35cecfaf41170e15c79" + integrity sha1-BbR5dctiHN/V2tNc7Pr0EXDhXHk= + object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" @@ -1620,6 +1640,14 @@ once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +openid-client@^6: + version "6.8.8" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/openid-client/-/openid-client-6.8.8.tgz#b18448b14c5468ef88b7c175bee6d80183d6331c" + integrity sha1-sYRIsUxUaO+It8F1vubYAYPWMxw= + dependencies: + jose "^6.2.12" + oauth4webapi "^3.8.8" + otplib@^13.5.0: version "13.5.0" resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.5.0.tgz#78b5d0d9edf83078d7fe37243bbcdd96d7a65d37" diff --git a/docs/src/guide/oidc.md b/docs/src/guide/oidc.md new file mode 100644 index 0000000000..6cce1067fb --- /dev/null +++ b/docs/src/guide/oidc.md @@ -0,0 +1,41 @@ +# OpenID Connect sign-in + +Nginx Proxy Manager can use a standard OIDC provider for sign-in. Enable it in +Settings → OpenID Connect using an issuer URL, client ID, client secret and the +public HTTPS URL of the NPM admin interface. Register the displayed callback +(`https://npm.example.com/api/oidc/callback`) as an exact authorization callback. + +Use a confidential client with Authorization Code and PKCE S256. The default +scope is `openid`; username, email and group claims are not required. Advanced +options support automatic login and standard token-endpoint authentication +methods (auto, client_secret_basic, client_secret_post). + +Sign in locally, open the user menu → OIDC account, and link your existing NPM +account. Linking/unlinking requires the current password and existing NPM 2FA +code if enabled. Identities are bound by issuer + subject, not matching email or +display name. Unknown identities cannot create accounts or gain administrator +privileges. Original user roles and proxy-host ownership are retained. + +Configure the identity provider's client access policies separately. NPM still +enforces its local user status, permissions and 2FA. Local password login remains +available at `/?local=1` even when automatic OIDC login is enabled. + +The callback validates state, nonce, PKCE, signature, issuer, audience and token +expiry through openid-client. It uses short-lived, one-time, browser-bound +cookies to exchange for an ordinary NPM API token. Neither provider tokens nor +NPM tokens are placed in URLs. Client secrets are encrypted using NPM's existing +persistent key and never returned by settings APIs. + +The HTTPS public URL is explicit: untrusted Host/X-Forwarded headers are not used +to choose the callback or token-exchange origin. Requests to cookie-bearing +endpoints must be same-origin JSON requests. Keep normal reverse-proxy forwarding +for /api/oidc; do not place a second interactive login challenge on the callback. + +Signing out ends the local UI session, not the identity-provider session. Already +issued NPM API tokens retain NPM's existing expiry/refresh behavior; provider +revocation alone is not immediate local token revocation. Disable the NPM user +to revoke local access immediately. Linking transactions expire after five +minutes, handoffs after one minute; both are discarded on process restart. + +Configuration/identity migrations support the same SQLite, MySQL and PostgreSQL +engines as NPM. Back up the database and /data/keys.json together before upgrades. diff --git a/frontend/src/api/backend/oidc.ts b/frontend/src/api/backend/oidc.ts new file mode 100644 index 0000000000..b7c025441b --- /dev/null +++ b/frontend/src/api/backend/oidc.ts @@ -0,0 +1,36 @@ +import { camelizeKeys, decamelizeKeys } from "humps"; +import AuthStore from "src/modules/AuthStore"; +import type { LoginResponse } from "./getToken"; + +export interface OIDCSettings { + enabled: boolean; + autoLogin: boolean; + issuer: string; + publicUrl: string; + clientId: string; + clientSecret: string; + clientSecretConfigured: boolean; + scopes: string; + revision: string; + callbackUrl: string; + tokenAuthMethod: "auto" | "client_secret_basic" | "client_secret_post"; +} +export async function oidcRequest(path: string, method = "GET", data?: object): Promise { + const response = await fetch(`/api/oidc/${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(AuthStore.token ? { Authorization: `Bearer ${AuthStore.token.token}` } : {}), + }, + body: data ? JSON.stringify(decamelizeKeys(data)) : undefined, + }); + if (!response.ok || response.redirected) + throw new Error("OIDC request failed. Check the settings or sign in locally."); + return camelizeKeys(await response.json()) as T; +} +export const getOIDCStatus = () => oidcRequest<{ enabled: boolean; autoLogin: boolean }>("status"); +export const exchangeOIDC = () => oidcRequest("exchange", "POST", {}); +export const startOIDC = async () => { + const result = await oidcRequest<{ url: string }>("start", "POST", {}); + window.location.assign(result.url); +}; diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index bf28640a0a..9ff8b0eb25 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -5,6 +5,7 @@ import { useUser } from "src/hooks"; import { T } from "src/locale"; import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals"; import styles from "./SiteHeader.module.css"; +import { showOIDCLinkModal } from "src/modals/OIDCLinkModal"; export function SiteHeader() { const { data: currentUser } = useUser("me"); @@ -123,6 +124,10 @@ export function SiteHeader() {
+ Promise; + completeOIDC: () => Promise; verifyTwoFactor: (code: string) => Promise; cancelTwoFactor: () => void; loginAs: (id: number) => Promise; @@ -63,6 +65,16 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) const response = await verify2FA(twoFactorChallenge.challengeToken, code); handleTokenUpdate(response); }; + const completeOIDC = async () => { + const response = await exchangeOIDC(); + window.history.replaceState(null, "", "/?local=1"); + if (isTwoFactorChallenge(response)) { + setTwoFactorChallenge({ challengeToken: response.challengeToken }); + return; + } + handleTokenUpdate(response); + window.history.replaceState(null, "", "/"); + }; const cancelTwoFactor = () => { setTwoFactorChallenge(null); @@ -83,6 +95,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) return; } AuthStore.clear(); + window.history.replaceState(null, "", "/?local=1"); setAuthenticated(false); queryClient.clear(); }; @@ -106,6 +119,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) authenticated, twoFactorChallenge, login, + completeOIDC, verifyTwoFactor, cancelTwoFactor, loginAs, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 88fdd724ed..87834c4403 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -566,6 +566,75 @@ "offline": { "defaultMessage": "Offline" }, + "oidc.advanced": { + "defaultMessage": "Advanced options" + }, + "oidc.auto-login": { + "defaultMessage": "Automatically start OIDC login" + }, + "oidc.callback": { + "defaultMessage": "Callback URL" + }, + "oidc.client-id": { + "defaultMessage": "Client ID" + }, + "oidc.client-secret": { + "defaultMessage": "Client secret" + }, + "oidc.description": { + "defaultMessage": "Connect a standard OIDC provider. Users link their existing NPM account from the user menu; NPM roles and local passwords are preserved. Control application access at the identity provider." + }, + "oidc.enabled": { + "defaultMessage": "Enable OIDC" + }, + "oidc.issuer": { + "defaultMessage": "Issuer URL" + }, + "oidc.link": { + "defaultMessage": "Link account" + }, + "oidc.link-description": { + "defaultMessage": "Link this NPM account to an identity at the configured provider. Your current password and existing two-factor verification are required." + }, + "oidc.link-success": { + "defaultMessage": "OIDC account linked successfully." + }, + "oidc.link-title": { + "defaultMessage": "OIDC account" + }, + "oidc.linked": { + "defaultMessage": "An OIDC identity is linked to this account." + }, + "oidc.public-url": { + "defaultMessage": "Public URL" + }, + "oidc.saved": { + "defaultMessage": "OIDC settings saved. Link your account from the user menu before testing login." + }, + "oidc.secret-kept": { + "defaultMessage": "Configured. Leave blank to keep the existing secret." + }, + "oidc.secret-new": { + "defaultMessage": "Enter the secret from your identity provider." + }, + "oidc.sign-in": { + "defaultMessage": "Sign in with OIDC" + }, + "oidc.signing-in": { + "defaultMessage": "Completing OIDC sign-in…" + }, + "oidc.title": { + "defaultMessage": "OpenID Connect" + }, + "oidc.token-auth": { + "defaultMessage": "Token endpoint authentication" + }, + "oidc.totp": { + "defaultMessage": "Verification code (if two-factor authentication is enabled)" + }, + "oidc.unlink": { + "defaultMessage": "Unlink account" + }, "online": { "defaultMessage": "Online" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index a5b5393b3e..6189f6c93b 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -566,6 +566,75 @@ "offline": { "defaultMessage": "Maas" }, + "oidc.advanced": { + "defaultMessage": "Täpsemad seaded" + }, + "oidc.auto-login": { + "defaultMessage": "Alusta OIDC sisselogimist automaatselt" + }, + "oidc.callback": { + "defaultMessage": "Tagasisuunamise URL" + }, + "oidc.client-id": { + "defaultMessage": "Kliendi ID" + }, + "oidc.client-secret": { + "defaultMessage": "Kliendi saladus" + }, + "oidc.description": { + "defaultMessage": "Ühenda standardne OIDC pakkuja. Kasutajad seovad olemasoleva NPM-i konto kasutajamenüüst; rollid ja kohalikud paroolid säilivad. Rakenduse ligipääsu haldab identiteedipakkuja." + }, + "oidc.enabled": { + "defaultMessage": "Luba OIDC" + }, + "oidc.issuer": { + "defaultMessage": "Väljastaja URL" + }, + "oidc.link": { + "defaultMessage": "Seo konto" + }, + "oidc.link-description": { + "defaultMessage": "Seo NPM-i konto identiteedipakkuja kontoga. Nõutud on praegune parool ja kasutusel olev kaheastmeline kinnitamine." + }, + "oidc.link-success": { + "defaultMessage": "OIDC konto edukalt seotud." + }, + "oidc.link-title": { + "defaultMessage": "OIDC konto" + }, + "oidc.linked": { + "defaultMessage": "Selle kontoga on seotud OIDC identiteet." + }, + "oidc.public-url": { + "defaultMessage": "Avalik URL" + }, + "oidc.saved": { + "defaultMessage": "OIDC seaded salvestatud. Seo konto kasutajamenüüst enne sisselogimise katsetamist." + }, + "oidc.secret-kept": { + "defaultMessage": "Seadistatud. Olemasoleva saladuse säilitamiseks jäta tühjaks." + }, + "oidc.secret-new": { + "defaultMessage": "Sisesta identiteedipakkuja antud saladus." + }, + "oidc.sign-in": { + "defaultMessage": "Logi sisse OIDC-ga" + }, + "oidc.signing-in": { + "defaultMessage": "OIDC sisselogimise lõpetamine…" + }, + "oidc.title": { + "defaultMessage": "OpenID Connecti sisselogimine" + }, + "oidc.token-auth": { + "defaultMessage": "Tokeni lõpp-punkti autentimine" + }, + "oidc.totp": { + "defaultMessage": "Kinnituskood, kui kaheastmeline autentimine on lubatud" + }, + "oidc.unlink": { + "defaultMessage": "Eemalda seos" + }, "online": { "defaultMessage": "Töös" }, diff --git a/frontend/src/locale/src/zh.json b/frontend/src/locale/src/zh.json index 72494bb64f..af6a36142f 100644 --- a/frontend/src/locale/src/zh.json +++ b/frontend/src/locale/src/zh.json @@ -470,6 +470,75 @@ "offline": { "defaultMessage": "离线" }, + "oidc.advanced": { + "defaultMessage": "高级选项" + }, + "oidc.auto-login": { + "defaultMessage": "自动发起 OIDC 登录" + }, + "oidc.callback": { + "defaultMessage": "回调地址" + }, + "oidc.client-id": { + "defaultMessage": "Client ID" + }, + "oidc.client-secret": { + "defaultMessage": "Client Secret" + }, + "oidc.description": { + "defaultMessage": "连接通用 OIDC 身份服务。用户在右上角菜单关联已有 NPM 账号,保留原权限与本地密码。请在身份服务中控制此应用的访问范围。" + }, + "oidc.enabled": { + "defaultMessage": "启用 OIDC" + }, + "oidc.issuer": { + "defaultMessage": "Issuer URL" + }, + "oidc.link": { + "defaultMessage": "关联账号" + }, + "oidc.link-description": { + "defaultMessage": "将当前 NPM 账号关联到身份服务。需要验证当前密码;已开启双重验证时还需验证码。" + }, + "oidc.link-success": { + "defaultMessage": "OIDC 账号关联成功。" + }, + "oidc.link-title": { + "defaultMessage": "OIDC 账号关联" + }, + "oidc.linked": { + "defaultMessage": "当前账号已关联 OIDC 身份。" + }, + "oidc.public-url": { + "defaultMessage": "公开访问地址" + }, + "oidc.saved": { + "defaultMessage": "OIDC 设置已保存。请在用户菜单关联账号后测试登录。" + }, + "oidc.secret-kept": { + "defaultMessage": "已配置,留空保留原密钥。" + }, + "oidc.secret-new": { + "defaultMessage": "填写身份服务提供的密钥。" + }, + "oidc.sign-in": { + "defaultMessage": "使用 OIDC 登录" + }, + "oidc.signing-in": { + "defaultMessage": "正在完成 OIDC 登录…" + }, + "oidc.title": { + "defaultMessage": "OIDC 单点登录" + }, + "oidc.token-auth": { + "defaultMessage": "Token 端点认证方式" + }, + "oidc.totp": { + "defaultMessage": "验证码(已开启双重验证时填写)" + }, + "oidc.unlink": { + "defaultMessage": "解除关联" + }, "online": { "defaultMessage": "在线" }, diff --git a/frontend/src/modals/OIDCLinkModal.tsx b/frontend/src/modals/OIDCLinkModal.tsx new file mode 100644 index 0000000000..bce12266df --- /dev/null +++ b/frontend/src/modals/OIDCLinkModal.tsx @@ -0,0 +1,92 @@ +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { useEffect, useState } from "react"; +import { Formik, Form, Field } from "formik"; +import { Alert, Modal } from "react-bootstrap"; +import { Button } from "src/components"; +import { oidcRequest } from "src/api/backend/oidc"; +import { T } from "src/locale"; + +const OIDCLinkModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { + const [identity, setIdentity] = useState<{ linked: boolean; issuer: string }>(); + const [error, setError] = useState(""); + useEffect(() => { + oidcRequest<{ linked: boolean; issuer: string }>("identity") + .then(setIdentity) + .catch((e) => setError(e.message)); + }, []); + return ( + + { + setError(""); + try { + if (identity?.linked) { + await oidcRequest("unlink", "POST", values); + remove(); + } else { + const result = await oidcRequest<{ url: string }>("link", "POST", values); + window.location.assign(result.url); + } + } catch (e) { + setError((e as Error).message); + setSubmitting(false); + } + }} + > + {({ isSubmitting }) => ( +
+ + + + + + + {error && {error}} +

+ +

+ {identity?.linked && {identity.issuer}} +
+ + + + +
+
+ + + + +
+ )} +
+
+ ); +}); +export const showOIDCLinkModal = () => EasyModal.show(OIDCLinkModal, {}); diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 5cb64867f0..8c48905159 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -1,5 +1,6 @@ import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; import { useNavigate } from "react-router-dom"; +import { Alert } from "react-bootstrap"; import { HasPermission } from "src/components"; import { useHostReport } from "src/hooks"; import { T } from "src/locale"; @@ -11,6 +12,11 @@ const Dashboard = () => { return (
+ {new URLSearchParams(window.location.search).get("oidc") === "linked" && ( + + + + )}

diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index d65b2dc84d..89517a75d5 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -1,5 +1,5 @@ import { Field, Form, Formik } from "formik"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Alert from "react-bootstrap/Alert"; import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; @@ -7,6 +7,65 @@ import { useHealth } from "src/hooks"; import { intl, T } from "src/locale"; import { validateEmail, validateString } from "src/modules/Validations"; import styles from "./index.module.css"; +import { getOIDCStatus, startOIDC } from "src/api/backend/oidc"; + +function OIDCLogin() { + const { completeOIDC } = useAuthState(); + const [enabled, setEnabled] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const started = useRef(false); + const begin = useCallback(async () => { + setBusy(true); + try { + await startOIDC(); + } catch { + setError("OIDC login failed. Use local login or check the provider settings."); + setBusy(false); + } + }, []); + useEffect(() => { + if (started.current) return; + started.current = true; + const query = new URLSearchParams(window.location.search); + if (query.get("oidc") === "complete") { + setBusy(true); + completeOIDC() + .catch(() => { + setError("OIDC login failed. Try again or sign in locally."); + window.history.replaceState(null, "", "/?local=1"); + getOIDCStatus() + .then((status) => setEnabled(status.enabled)) + .catch(() => {}); + }) + .finally(() => setBusy(false)); + return; + } + getOIDCStatus() + .then((status) => { + setEnabled(status.enabled); + if (status.enabled && status.autoLogin && !query.has("local")) begin(); + }) + .catch(() => {}); + }, [completeOIDC, begin]); + return ( + <> + {error && {error}} + {enabled && ( +
+ +
+ )} + {busy && ( +

+ +

+ )} + + ); +} function TwoFactorForm() { const codeRef = useRef(null); @@ -103,6 +162,7 @@ function LoginForm() {

+ {formErr !== "" && {formErr}}
e.preventDefault()} + className={ + "list-group-item list-group-item-action d-flex align-items-center" + + (section === "default" ? " active" : "") + } + onClick={(e) => { + e.preventDefault(); + setSection("default"); + }} > + { + e.preventDefault(); + setSection("oidc"); + }} + > + +
- + {section === "oidc" ? : }
diff --git a/frontend/src/pages/Settings/OIDC.test.tsx b/frontend/src/pages/Settings/OIDC.test.tsx new file mode 100644 index 0000000000..d71f1b7325 --- /dev/null +++ b/frontend/src/pages/Settings/OIDC.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import OIDC from "./OIDC"; +const request = vi.hoisted(() => vi.fn()); +vi.mock("src/api/backend/oidc", () => ({ oidcRequest: request })); +vi.mock("src/locale", () => ({ + T: ({ id }: { id: string }) => id, + intl: { formatMessage: ({ id }: { id: string }) => id }, +})); +vi.mock("src/components", () => ({ + Loading: () => null, + Button: ({ children, isLoading, actionType, ...props }: any) => , +})); +const config = { + enabled: true, + autoLogin: false, + issuer: "https://id.example", + publicUrl: "https://npm.example", + clientId: "client", + clientSecretConfigured: true, + scopes: "openid", + revision: "r1", + callbackUrl: "https://npm.example/api/oidc/callback", + tokenAuthMethod: "auto", +}; +describe("OIDC settings", () => { + beforeEach(() => request.mockReset()); + it("keeps secrets blank and locks the dedicated form until a delayed save completes", async () => { + let finish: (v: unknown) => void = () => {}; + request.mockResolvedValueOnce(config).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + const issuer = await screen.findByLabelText("oidc.issuer"); + expect(screen.getByLabelText("oidc.client-secret")).toHaveValue(""); + fireEvent.change(issuer, { target: { value: "https://new.example" } }); + fireEvent.click(screen.getByRole("button", { name: "save" })); + await waitFor(() => expect(issuer).toBeDisabled()); + expect(screen.getByLabelText("oidc.client-secret")).toBeDisabled(); + expect(request.mock.calls[1][0]).toBe("settings"); + expect(request.mock.calls[1][1]).toBe("PUT"); + expect(request.mock.calls[1][2].clientSecret).toBe(""); + finish({ ...config, issuer: "https://new.example", revision: "r2" }); + await waitFor(() => expect(issuer).not.toBeDisabled()); + expect(await screen.findByText("oidc.saved")).toBeVisible(); + }); +}); diff --git a/frontend/src/pages/Settings/OIDC.tsx b/frontend/src/pages/Settings/OIDC.tsx new file mode 100644 index 0000000000..cf59ee6d3a --- /dev/null +++ b/frontend/src/pages/Settings/OIDC.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from "react"; +import { Formik, Form, Field } from "formik"; +import { Alert } from "react-bootstrap"; +import { Button, Loading } from "src/components"; +import { oidcRequest, type OIDCSettings } from "src/api/backend/oidc"; +import { T, intl } from "src/locale"; + +export default function OIDC() { + const [data, setData] = useState(); + const [error, setError] = useState(""); + const [saved, setSaved] = useState(false); + useEffect(() => { + oidcRequest("settings") + .then((v) => setData({ ...v, clientSecret: "", publicUrl: v.publicUrl || window.location.origin })) + .catch((e) => setError(e.message)); + }, []); + if (!data) + return
{error ? {error} : }
; + return ( + { + setError(""); + setSaved(false); + try { + const result = await oidcRequest("settings", "PUT", values); + setData({ ...result, clientSecret: "" }); + setSaved(true); + } catch (e) { + setError((e as Error).message); + } finally { + setSubmitting(false); + } + }} + > + {({ isSubmitting, values }) => ( +
+
+

+ +

+

+ +

+ {error && {error}} + {saved && ( + + + + )} +
+ + {( + [ + ["issuer", "oidc.issuer", "url"], + ["clientId", "oidc.client-id", "text"], + ["clientSecret", "oidc.client-secret", "password"], + ["publicUrl", "oidc.public-url", "url"], + ] as const + ).map(([name, label, type]) => ( +
+ + + {name === "clientSecret" && ( + + + + )} +
+ ))} +
+
+ +
+ {values.publicUrl.replace(/\/$/, "")}/api/oidc/callback +
+
+ + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+ )} +
+ ); +} From 684129749174b0d7c8401763adc957f67c6ed948 Mon Sep 17 00:00:00 2001 From: Huiting Chen Date: Fri, 18 Sep 2026 08:22:16 +0800 Subject: [PATCH 2/4] feat(oidc): manage account links from profile login methods --- backend/internal/oidc.js | 27 +- backend/routes/oidc.js | 38 +- backend/test/oidc.test.js | 125 ++++-- docs/src/guide/oidc.md | 9 +- frontend/src/components/LoginMethods.test.tsx | 42 ++ frontend/src/components/LoginMethods.tsx | 76 ++++ frontend/src/components/SiteHeader.tsx | 5 - frontend/src/locale/src/en.json | 15 +- frontend/src/locale/src/et.json | 15 +- frontend/src/locale/src/zh.json | 15 +- frontend/src/modals/OIDCLinkModal.tsx | 92 ----- frontend/src/modals/UserModal.tsx | 382 ++++++++++-------- 12 files changed, 502 insertions(+), 339 deletions(-) create mode 100644 frontend/src/components/LoginMethods.test.tsx create mode 100644 frontend/src/components/LoginMethods.tsx delete mode 100644 frontend/src/modals/OIDCLinkModal.tsx diff --git a/backend/internal/oidc.js b/backend/internal/oidc.js index 1bc3f5445b..e031614bec 100644 --- a/backend/internal/oidc.js +++ b/backend/internal/oidc.js @@ -4,7 +4,6 @@ import { seal, unseal, validateConfig, discover, random, identityKey } from "../ import errs from "../lib/error.js"; import userModel from "../models/user.js"; import authModel from "../models/auth.js"; -import twoFactor from "./2fa.js"; import TokenModel from "../models/token.js"; import internalToken from "./token.js"; import crypto from "node:crypto"; @@ -13,11 +12,11 @@ const authStamp = (auth) => .createHash("sha256") .update( JSON.stringify([ - auth.secret, - auth.meta?.password_changed_at, - auth.meta?.totp_enabled, - auth.meta?.totp_secret, - auth.meta?.totp_enabled_at, + auth?.secret, + auth?.meta?.password_changed_at, + auth?.meta?.totp_enabled, + auth?.meta?.totp_secret, + auth?.meta?.totp_enabled_at, ]), ) .digest("hex"); @@ -103,17 +102,9 @@ export async function activeUser(id) { if (!user) throw new errs.AuthError("Account is unavailable"); return user; } -export async function reauthenticate(id, password, code) { +export async function authenticationStamp(id) { await activeUser(id); const auth = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); - if (typeof password !== "string" || password.length > 1024 || !auth || !(await auth.verifyPassword(password))) - throw new errs.AuthError("Invalid current password"); - if (auth.meta?.totp_enabled) { - if (typeof code !== "string" || code.length > 32 || !(await twoFactor.verifyForLogin(id, code))) - throw new errs.AuthError("Invalid verification code"); - } - const latest = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); - if (!latest || authStamp(latest) !== authStamp(auth)) throw new errs.AuthError("Authentication changed; try again"); return authStamp(auth); } export async function linkIdentity(id, issuer, subject, stamp) { @@ -124,8 +115,7 @@ export async function linkIdentity(id, issuer, subject, stamp) { .where({ user_id: id, type: "password", is_deleted: 0 }) .forUpdate() .first(); - if (!auth || authStamp(auth) !== stamp) - throw new errs.AuthError("Local authentication changed; start linking again"); + if (authStamp(auth) !== stamp) throw new errs.AuthError("Local authentication changed; start linking again"); const user = await userModel.query(trx).where({ id, is_deleted: 0, is_disabled: 0 }).first(); if (!user) throw new errs.AuthError("Account is unavailable"); const current = await trx("oidc_identity").where({ user_id: id }).first(); @@ -143,7 +133,8 @@ export async function linkedUser(issuer, subject) { } export async function loginResult(user) { await activeUser(user.id); - if (await twoFactor.isEnabled(user.id)) { + const auth = await authModel.query().where({ user_id: user.id, type: "password", is_deleted: 0 }).first(); + if (auth?.meta?.totp_enabled === true) { const signed = await TokenModel().create({ iss: "api", attrs: { id: user.id }, diff --git a/backend/routes/oidc.js b/backend/routes/oidc.js index 73fa52232b..6ffbc4660f 100644 --- a/backend/routes/oidc.js +++ b/backend/routes/oidc.js @@ -4,6 +4,8 @@ import { client, discover, OneTimeStore, sameOrigin } from "../lib/oidc.js"; import * as service from "../internal/oidc.js"; import jwtdecode from "../lib/express/jwt-decode.js"; import db from "../db.js"; +import Access from "../lib/access.js"; +import { validateConfig } from "../lib/oidc.js"; const router = express.Router(); const transactions = new OneTimeStore(); @@ -63,30 +65,44 @@ router.get( const id = await requireUser(res); await service.activeUser(id); const row = await db()("oidc_identity").where({ user_id: id }).first(); - res.json({ linked: !!row, issuer: row?.issuer || "" }); + const { config } = await service.readConfig(); + let available = false; + try { + validateConfig(config); + available = config.enabled; + } catch { + /* Incomplete provider configuration. */ + } + res.json({ linked: !!row, issuer: row?.issuer || "", available }); }), ); router.post( "/unlink", jwtdecode(), handler(async (req, res) => { - const { config } = await service.readConfig(); - if (!sameOrigin(req, config)) return res.sendStatus(403); + // Local account management must work after the provider is removed. The + // bearer session is mandatory; never use forwarded headers as a trust source. + if (!req.is("application/json") || (req.get("Sec-Fetch-Site") && req.get("Sec-Fetch-Site") !== "same-origin")) + return res.sendStatus(403); + // NPM's internal nginx forwards $host without its port. Sec-Fetch-Site + // checks modern browser origins; hostname comparison covers older clients. + if (req.get("Origin") && new URL(req.get("Origin")).hostname !== new URL(`http://${req.get("Host")}`).hostname) + return res.sendStatus(403); const id = await requireUser(res); - await service.reauthenticate(id, req.body.password, req.body.code); await db()("oidc_identity").where({ user_id: id }).delete(); res.json({ linked: false }); }), ); -async function start(req, res, userId = null, stamp = null) { +async function start(req, res, userId = null, stamp = null, token = null) { const { config, revision } = await service.readConfig(); if (!config.enabled) return res.sendStatus(404); + validateConfig(config); if (!sameOrigin(req, config)) return res.sendStatus(403); const provider = await discover(config, revision); const state = client.randomState(); const nonce = client.randomNonce(); const verifier = client.randomPKCECodeVerifier(); - const tx = transactions.put({ state, nonce, verifier, revision, userId, stamp }); + const tx = transactions.put({ state, nonce, verifier, revision, userId, stamp, token }); putCookie(res, txCookie, tx, 300); const url = client.buildAuthorizationUrl(provider, { redirect_uri: new URL("/api/oidc/callback", config.public_url).href, @@ -109,8 +125,10 @@ router.post( const { config } = await service.readConfig(); if (!sameOrigin(req, config)) return res.sendStatus(403); const id = await requireUser(res); - const stamp = await service.reauthenticate(id, req.body.password, req.body.code); - return start(req, res, id, stamp); + if (await db()("oidc_identity").where({ user_id: id }).first()) + return res.status(409).json({ error: { message: "An OIDC identity is already linked" } }); + const stamp = await service.authenticationStamp(id); + return start(req, res, id, stamp, res.locals.token); }), ); router.get("/callback", async (req, res) => { @@ -133,6 +151,10 @@ router.get("/callback", async (req, res) => { const current = await service.readConfig(); if (current.revision !== revision || !current.config.enabled) throw new Error("Configuration changed"); if (tx.userId) { + // The original authenticated session must still be valid after the IdP round trip. + const access = new Access(tx.token); + await access.can("users:get", tx.userId); + if (access.token.getUserId() !== tx.userId) throw new Error("Account changed"); await service.linkIdentity(tx.userId, claims.iss, claims.sub, tx.stamp); return res.redirect(303, new URL("/?oidc=linked", config.public_url).href); } diff --git a/backend/test/oidc.test.js b/backend/test/oidc.test.js index 9800b7f437..4da9d077e4 100644 --- a/backend/test/oidc.test.js +++ b/backend/test/oidc.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import crypto from "node:crypto"; import knex from "knex"; import express from "express"; +import { request as httpRequest } from "node:http"; import { generateKeyPair, exportJWK, SignJWT } from "jose"; const keys = crypto.generateKeyPairSync("rsa", { @@ -133,14 +134,35 @@ test("OIDC integration with standard-only signed tokens and existing NPM account await new Promise((resolve) => server.close(resolve)); await database.destroy(); }); - async function req(path, { token, body, cookie, method = body ? "POST" : "GET", requestOrigin = origin } = {}) { + async function req( + path, + { token, body, cookie, method = body ? "POST" : "GET", requestOrigin = origin, host } = {}, + ) { const headers = { "Content-Type": "application/json", Origin: requestOrigin, "Sec-Fetch-Site": "same-origin", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(cookie ? { Cookie: cookie } : {}), + ...(host ? { Host: host } : {}), }; + if (host) + return new Promise((resolve, reject) => { + const request = httpRequest(`${base}/api/oidc/${path}`, { method, headers }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => + resolve( + new Response(Buffer.concat(chunks), { + status: response.statusCode, + headers: response.headers, + }), + ), + ); + }); + request.on("error", reject); + request.end(body ? JSON.stringify(body) : undefined); + }); return nativeFetch(`${base}/api/oidc/${path}`, { method, headers, @@ -178,7 +200,7 @@ test("OIDC integration with standard-only signed tokens and existing NPM account assert.equal((await service.readConfig()).config.public_url, origin); }); async function start(token) { - const r = await req(token ? "link" : "start", { token, body: token ? { password: "current-password" } : {} }); + const r = await req(token ? "link" : "start", { token, body: {} }); assert.equal(r.status, 200); const url = new URL((await r.json()).url); nonce = url.searchParams.get("nonce"); @@ -236,6 +258,8 @@ test("OIDC integration with standard-only signed tokens and existing NPM account const tx = await start(); assert.equal((await callback({ ...tx, cookie: "" })).status, 401); assert.notEqual((await req("identity", { token: challenge })).status, 200); + assert.notEqual((await req("link", { token: challenge, body: {} })).status, 200); + assert.notEqual((await req("link", { body: {} })).status, 200); assert.equal((await req("start", { body: {}, requestOrigin: "https://evil.example" })).status, 403); }); await t.test("disabled users and unlinked handoffs fail closed", async () => { @@ -257,33 +281,34 @@ test("OIDC integration with standard-only signed tokens and existing NPM account assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); subject = "subject-1"; }); - await t.test("password reset during password verification cannot mint a fresh link stamp", async () => { - const original = Auth.prototype.verifyPassword; - let began; - let release; - const started = new Promise((r) => { - began = r; - }); - const barrier = new Promise((r) => { - release = r; - }); - Auth.prototype.verifyPassword = async () => { - began(); - await barrier; - return true; - }; - try { - const result = service.reauthenticate(2, "new-password").then( - () => false, - () => true, - ); - await started; - await Auth.query().where({ user_id: 2, type: "password" }).patch({ secret: "reset-during-verification" }); - release(); - assert.equal(await result, true); - } finally { - Auth.prototype.verifyPassword = original; - } + await t.test("standard users link only themselves using a valid session without a password", async () => { + subject = "standard-subject"; + const tx = await start(regular); + assert.equal((await callback(tx)).status, 303); + const row = await database("oidc_identity").where({ user_id: 2 }).first(); + assert.equal(row.subject, subject); + assert.equal((await database("user").where({ id: 2 }).first()).roles, "[]"); + const own = await req("identity?user_id=1", { token: regular }); + assert.equal((await own.json()).issuer, "https://id.example"); + assert.equal((await req("unlink", { token: regular, body: { user_id: 1 }, requestOrigin: base })).status, 200); + assert.ok(await database("oidc_identity").where({ user_id: 1 }).first()); + assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); + subject = "subject-1"; + }); + await t.test("linking requires an enabled saved provider and rejects expired or disabled sessions", async () => { + const row = await database("oidc_config").where({ id: 1 }).first(); + const saved = JSON.parse(row.config); + await database("oidc_config") + .where({ id: 1 }) + .update({ config: JSON.stringify({ ...saved, enabled: false }) }); + assert.equal((await (await req("identity", { token: regular })).json()).available, false); + assert.equal((await req("link", { token: regular, body: {} })).status, 404); + await database("oidc_config").where({ id: 1 }).update({ config: row.config }); + const expired = (await Token().create({ attrs: { id: 2 }, scope: ["user"], expiresIn: "-1s" })).token; + assert.notEqual((await req("link", { token: expired, body: {} })).status, 200); + await database("user").where({ id: 2 }).update({ is_disabled: 1 }); + assert.notEqual((await req("link", { token: regular, body: {} })).status, 200); + await database("user").where({ id: 2 }).update({ is_disabled: 0 }); }); await t.test("NPM two-factor remains required after OIDC", async () => { const auth = await Auth.query().where({ user_id: 1, type: "password" }).first(); @@ -294,6 +319,48 @@ test("OIDC integration with standard-only signed tokens and existing NPM account assert.equal(result.requires_2fa, true); assert.equal(result.token, undefined); }); + await t.test("a session expiring during the IdP round trip cannot finish linking", async () => { + const short = (await Token().create({ attrs: { id: 2 }, scope: ["user"], expiresIn: "1s" })).token; + const tx = await start(short); + await new Promise((resolve) => setTimeout(resolve, 1100)); + assert.equal((await callback(tx)).status, 401); + assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); + }); + await t.test( + "valid-session accounts without passwords can link and sign in; unlink works after provider removal", + async () => { + await database("auth").where({ user_id: 2 }).delete(); + subject = "passwordless-subject"; + const tx = await start(regular); + assert.equal((await callback(tx)).status, 303); + const result = await service.loginResult(await service.linkedUser("https://id.example", subject)); + assert.ok(result.token); + assert.equal(result.requires_2fa, undefined); + const row = await database("oidc_config").where({ id: 1 }).first(); + const cfg = JSON.parse(row.config); + await database("oidc_config") + .where({ id: 1 }) + .update({ config: JSON.stringify({ ...cfg, enabled: false, public_url: "", issuer: "" }) }); + assert.equal( + (await req("unlink", { token: regular, body: {}, requestOrigin: "https://evil.example" })).status, + 403, + ); + assert.equal( + ( + await req("unlink", { + token: regular, + body: {}, + requestOrigin: "http://nas.example:81", + host: "nas.example", + }) + ).status, + 200, + ); + assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); + await database("oidc_config").where({ id: 1 }).update({ config: row.config }); + subject = "subject-1"; + }, + ); assert.ok(discoveryCount < 4); await down(database); assert.equal(await database.schema.hasTable("oidc_identity"), false); diff --git a/docs/src/guide/oidc.md b/docs/src/guide/oidc.md index 6cce1067fb..d1c4de505a 100644 --- a/docs/src/guide/oidc.md +++ b/docs/src/guide/oidc.md @@ -10,9 +10,12 @@ scope is `openid`; username, email and group claims are not required. Advanced options support automatic login and standard token-endpoint authentication methods (auto, client_secret_basic, client_secret_post). -Sign in locally, open the user menu → OIDC account, and link your existing NPM -account. Linking/unlinking requires the current password and existing NPM 2FA -code if enabled. Identities are bound by issuer + subject, not matching email or +Open the user menu → Edit Profile → Login methods to link your own existing +NPM account. Administrators and standard users use the same area. Linking and +unlinking require a valid authenticated NPM user session, without another +password prompt or a recent-login requirement. Linking is available only after +an administrator saves and enables a valid OIDC provider. +Identities are bound by issuer + subject, not matching email or display name. Unknown identities cannot create accounts or gain administrator privileges. Original user roles and proxy-host ownership are retained. diff --git a/frontend/src/components/LoginMethods.test.tsx b/frontend/src/components/LoginMethods.test.tsx new file mode 100644 index 0000000000..a95ddd01c8 --- /dev/null +++ b/frontend/src/components/LoginMethods.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { test, expect, vi, afterEach } from "vitest"; +import { LoginMethods } from "./LoginMethods"; +const request = vi.hoisted(() => vi.fn()); +vi.mock("src/api/backend/oidc", () => ({ oidcRequest: request })); +vi.mock("src/locale", () => ({ T: ({ id }: { id: string }) => id })); +vi.mock("src/components", () => ({ + Loading: () => null, + Button: ({ children, isLoading, actionType, ...props }: any) => , +})); +afterEach(() => { + cleanup(); + request.mockReset(); + vi.restoreAllMocks(); +}); +test("disabled provider prevents linking, without any password fields", async () => { + request.mockResolvedValue({ linked: false, issuer: "", available: false }); + render(); + expect(await screen.findByRole("button", { name: "oidc.link" })).toBeDisabled(); + expect(screen.getByText("oidc.not-configured")).toBeVisible(); + expect(document.querySelector('input[type="password"]')).toBeNull(); +}); +test("link uses the existing session with an empty payload", async () => { + request + .mockResolvedValueOnce({ linked: false, issuer: "", available: true }) + .mockResolvedValueOnce({ url: "https://id.example/authorize" }); + const navigate = vi.spyOn(window.location, "assign").mockImplementation(() => {}); + render(); + fireEvent.click(await screen.findByRole("button", { name: "oidc.link" })); + await waitFor(() => expect(navigate).toHaveBeenCalledWith("https://id.example/authorize")); + expect(request).toHaveBeenLastCalledWith("link", "POST", {}); +}); +test("linked account can unlink using the same area", async () => { + request + .mockResolvedValueOnce({ linked: true, issuer: "https://id.example", available: true }) + .mockResolvedValueOnce({ linked: false }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "oidc.unlink" })); + await screen.findByRole("button", { name: "oidc.link" }); + expect(request).toHaveBeenLastCalledWith("unlink", "POST", {}); +}); diff --git a/frontend/src/components/LoginMethods.tsx b/frontend/src/components/LoginMethods.tsx new file mode 100644 index 0000000000..0895de0d98 --- /dev/null +++ b/frontend/src/components/LoginMethods.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { Alert } from "react-bootstrap"; +import { Button, Loading } from "src/components"; +import { oidcRequest } from "src/api/backend/oidc"; +import { T } from "src/locale"; + +interface Identity { + linked: boolean; + issuer: string; + available: boolean; +} + +// Both administrator and standard accounts manage only their own identity here. +export function LoginMethods() { + const [identity, setIdentity] = useState(); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => { + oidcRequest("identity") + .then(setIdentity) + .catch((e) => setError(e.message)); + }, []); + const act = async () => { + if (busy || !identity) return; + setBusy(true); + setError(""); + try { + if (identity.linked) { + await oidcRequest("unlink", "POST", {}); + setIdentity({ ...identity, linked: false, issuer: "" }); + setBusy(false); + } else { + const { url } = await oidcRequest<{ url: string }>("link", "POST", {}); + window.location.assign(url); + } + } catch (e) { + setError((e as Error).message); + setBusy(false); + } + }; + return ( + <> + {error && {error}} + {!identity && !error && } + {identity && ( + <> +

+ +

+

+ +

+ {identity.linked && ( +

+ {identity.issuer} +

+ )} + {!identity.available && ( + + + + )} + + + )} + + ); +} diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index 9ff8b0eb25..bf28640a0a 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -5,7 +5,6 @@ import { useUser } from "src/hooks"; import { T } from "src/locale"; import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals"; import styles from "./SiteHeader.module.css"; -import { showOIDCLinkModal } from "src/modals/OIDCLinkModal"; export function SiteHeader() { const { data: currentUser } = useUser("me"); @@ -124,10 +123,6 @@ export function SiteHeader() {
- { - const [identity, setIdentity] = useState<{ linked: boolean; issuer: string }>(); - const [error, setError] = useState(""); - useEffect(() => { - oidcRequest<{ linked: boolean; issuer: string }>("identity") - .then(setIdentity) - .catch((e) => setError(e.message)); - }, []); - return ( - - { - setError(""); - try { - if (identity?.linked) { - await oidcRequest("unlink", "POST", values); - remove(); - } else { - const result = await oidcRequest<{ url: string }>("link", "POST", values); - window.location.assign(result.url); - } - } catch (e) { - setError((e as Error).message); - setSubmitting(false); - } - }} - > - {({ isSubmitting }) => ( -
- - - - - - - {error && {error}} -

- -

- {identity?.linked && {identity.issuer}} -
- - - - -
-
- - - - -
- )} -
-
- ); -}); -export const showOIDCLinkModal = () => EasyModal.show(OIDCLinkModal, {}); diff --git a/frontend/src/modals/UserModal.tsx b/frontend/src/modals/UserModal.tsx index 06bc38cf98..383fbc7db5 100644 --- a/frontend/src/modals/UserModal.tsx +++ b/frontend/src/modals/UserModal.tsx @@ -8,6 +8,7 @@ import { useSetUser, useUser } from "src/hooks"; import { intl, T } from "src/locale"; import { validateEmail, validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; +import { LoginMethods } from "src/components/LoginMethods"; const showUserModal = (id: number | "me" | "new") => { EasyModal.show(UserModal, { id }); @@ -22,6 +23,8 @@ const UserModal = EasyModal.create(({ id, visible, remove }: Props) => { const { mutate: setUser } = useSetUser(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [section, setSection] = useState("profile"); + const isSelf = id !== "new" && !!data && data.id === currentUser?.id; const onSubmit = async (values: any, { setSubmitting }: any) => { if (isSubmitting) return; @@ -60,6 +63,47 @@ const UserModal = EasyModal.create(({ id, visible, remove }: Props) => { return ( + + + + + + {isSelf && ( +
+ + +
+ )} + {isSelf && section === "login" && ( + + + + )} {!isLoading && error && ( {error?.message || "Unknown error"} @@ -67,177 +111,183 @@ const UserModal = EasyModal.create(({ id, visible, remove }: Props) => { )} {(isLoading || currentIsLoading) && } {!isLoading && !currentIsLoading && data && currentUser && ( - - {() => ( -
- - - - - - - setErrorMsg(null)} dismissible> - {errorMsg} - -
-
-
- - {({ field, form }: any) => ( -
- - - {form.errors.name ? ( -
- {form.errors.name && form.touched.name - ? form.errors.name - : null} -
- ) : null} -
- )} -
+