diff --git a/README.md b/README.md index 5cd9be1c96..27ee4bddcc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ so that the barrier to entry here is low. - Access Lists and basic HTTP Authentication for your hosts - Advanced Nginx configuration available for super users - User management, permissions, and audit log +- OpenID Connect (OIDC) single sign-on ::: warning `armv7` is no longer supported in version 2.14+. This is due to Nodejs dropping support for armhf. Please diff --git a/backend/internal/oidc.js b/backend/internal/oidc.js new file mode 100644 index 0000000000..d6a616be2c --- /dev/null +++ b/backend/internal/oidc.js @@ -0,0 +1,163 @@ +import crypto from "node:crypto"; +import db from "../db.js"; +import { getPrivateKey } from "../lib/config.js"; +import errs from "../lib/error.js"; +import { discover, identityKey, random, seal, unseal, validateConfig } from "../lib/oidc.js"; +import authModel from "../models/auth.js"; +import TokenModel from "../models/token.js"; +import userModel from "../models/user.js"; +import internalToken from "./token.js"; + +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 authenticationStamp(id) { + await activeUser(id); + const auth = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); + return authStamp(auth); +} +export async function canUnlinkIdentity(id) { + const auth = await authModel.query().where({ user_id: id, type: "password", is_deleted: 0 }).first(); + return typeof auth?.secret === "string" && auth.secret.length > 0; +} +export async function linkIdentity(id, issuer, subject, stamp, revision) { + await activeUser(id); + await db().transaction(async (trx) => { + const provider = await trx("oidc_config").where({ id: 1 }).forUpdate().first(); + if (!provider || provider.revision !== revision || JSON.parse(provider.config).enabled !== true) + throw new errs.AuthError("OIDC configuration changed; start linking again"); + const auth = await authModel + .query(trx) + .where({ user_id: id, type: "password", is_deleted: 0 }) + .forUpdate() + .first(); + 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(); + if (current) throw new errs.ValidationError("An OIDC identity is already linked"); + const key = identityKey(issuer, subject); + const existing = await trx("oidc_identity").where({ identity_key: key }).forUpdate().first(); + if (existing) { + const owner = await userModel.query(trx).where({ id: existing.user_id }).forUpdate().first(); + if (!owner?.is_deleted) throw new errs.ValidationError("Identity is already linked to another account"); + await trx("oidc_identity").where({ id: existing.id }).delete(); + } + await trx("oidc_identity").insert({ user_id: id, identity_key: key, 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); + 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 }, + 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..0e0cccc5c4 --- /dev/null +++ b/backend/lib/oidc.js @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import { LRUCache } from "lru-cache"; +import * as client from "openid-client"; + +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..9805f47d47 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -12,6 +12,7 @@ import deadHostsRoutes from "./nginx/dead_hosts.js"; import proxyHostsRoutes from "./nginx/proxy_hosts.js"; import redirectionHostsRoutes from "./nginx/redirection_hosts.js"; import streamsRoutes from "./nginx/streams.js"; +import oidcRoutes from "./oidc.js"; import reportsRoutes from "./reports.js"; import schemaRoutes from "./schema.js"; import settingsRoutes from "./settings.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..19c00c8c52 --- /dev/null +++ b/backend/routes/oidc.js @@ -0,0 +1,198 @@ +import { parse, serialize } from "cookie"; +import express from "express"; +import db from "../db.js"; +import * as service from "../internal/oidc.js"; +import Access from "../lib/access.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import { client, discover, OneTimeStore, sameOrigin, validateConfig } from "../lib/oidc.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(); + 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, + can_unlink: await service.canUnlinkIdentity(id), + }); + }), +); +router.post( + "/unlink", + jwtdecode(), + handler(async (req, res) => { + // 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); + if (!(await service.canUnlinkIdentity(id))) + return res + .status(409) + .json({ error: { message: "Set a local password before unlinking your last login method" } }); + await db()("oidc_identity").where({ user_id: id }).delete(); + res.json({ linked: false }); + }), +); +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, token }); + 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); + 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) => { + 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) { + // 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, revision); + 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/schema/components/oidc-error.json b/backend/schema/components/oidc-error.json new file mode 100644 index 0000000000..955ae40e78 --- /dev/null +++ b/backend/schema/components/oidc-error.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "integer" + } + } + } + } +} diff --git a/backend/schema/components/oidc-settings-object.json b/backend/schema/components/oidc-settings-object.json new file mode 100644 index 0000000000..e5b6b2ff84 --- /dev/null +++ b/backend/schema/components/oidc-settings-object.json @@ -0,0 +1,56 @@ +{ + "type": "object", + "required": [ + "enabled", + "auto_login", + "issuer", + "public_url", + "client_id", + "scopes", + "token_auth_method", + "revision", + "client_secret_configured", + "callback_url" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "auto_login": { + "type": "boolean" + }, + "issuer": { + "type": "string", + "description": "HTTPS issuer URL; may be empty when disabled." + }, + "public_url": { + "type": "string", + "description": "HTTPS origin of the NPM admin interface; may be empty when disabled." + }, + "client_id": { + "type": "string" + }, + "scopes": { + "type": "string", + "description": "Space-separated scopes including openid when enabled." + }, + "token_auth_method": { + "type": "string", + "enum": [ + "auto", + "client_secret_basic", + "client_secret_post" + ] + }, + "revision": { + "type": "string", + "description": "Opaque revision used for optimistic concurrency." + }, + "client_secret_configured": { + "type": "boolean" + }, + "callback_url": { + "type": "string" + } + } +} diff --git a/backend/schema/paths/oidc/callback/get.json b/backend/schema/paths/oidc/callback/get.json new file mode 100644 index 0000000000..ba55c982da --- /dev/null +++ b/backend/schema/paths/oidc/callback/get.json @@ -0,0 +1,71 @@ +{ + "tags": [ + "oidc" + ], + "operationId": "completeOidcCallback", + "summary": "Handle the provider authorization callback", + "description": "Browser redirect endpoint. Validates the authorization response, state, nonce and PKCE against a one-time transaction. Linking retains the existing NPM session; login creates a one-time handoff for POST /oidc/exchange. Unknown identities cannot create accounts.", + "security": [], + "parameters": [ + { + "in": "cookie", + "name": "__Host-npm_oidc_tx", + "required": true, + "description": "Browser-bound transaction set by start or link.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error", + "description": "Provider authorization error instead of a code.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "303": { + "description": "Redirect to the configured public origin with oidc=linked or oidc=complete.", + "headers": { + "Location": { + "schema": { + "type": "string", + "format": "uri" + } + }, + "Set-Cookie": { + "description": "Clears the transaction cookie. Successful login also sets __Host-npm_oidc_handoff for one minute with Secure, HttpOnly, Path=/ and SameSite=Lax.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "OIDC callback rejected. Returns an HTML page with a local-login link.", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/exchange/post.json b/backend/schema/paths/oidc/exchange/post.json new file mode 100644 index 0000000000..30f5808814 --- /dev/null +++ b/backend/schema/paths/oidc/exchange/post.json @@ -0,0 +1,82 @@ +{ + "tags": [ + "oidc" + ], + "operationId": "exchangeOidcHandoff", + "summary": "Exchange an OIDC handoff for an NPM session", + "description": "Consumes the browser-bound handoff exactly once. Rechecks the linked local account and enabled provider. Local NPM 2FA remains required when configured.", + "security": [], + "parameters": [ + { + "in": "header", + "name": "Origin", + "required": true, + "description": "Must exactly match the configured public HTTPS origin. Sec-Fetch-Site, if sent, must be same-origin.", + "schema": { + "type": "string", + "format": "uri" + } + }, + { + "in": "cookie", + "name": "__Host-npm_oidc_handoff", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Ordinary NPM token or a local 2FA challenge for POST /tokens/2fa.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "../../../components/token-object.json" + }, + { + "$ref": "../../../components/token-challenge.json" + } + ] + } + } + }, + "headers": { + "Set-Cookie": { + "description": "Clears the handoff cookie.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Exchange failed or the local account is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Handoff missing, expired, consumed, unlinked, or invalidated by a configuration change." + }, + "403": { + "description": "Request is not same-origin JSON." + } + } +} diff --git a/backend/schema/paths/oidc/identity/get.json b/backend/schema/paths/oidc/identity/get.json new file mode 100644 index 0000000000..3493a566c4 --- /dev/null +++ b/backend/schema/paths/oidc/identity/get.json @@ -0,0 +1,61 @@ +{ + "tags": [ + "oidc" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "operationId": "getOidcIdentity", + "summary": "Get the current user\u2019s OIDC link", + "description": "Requires a full authenticated NPM user session. Always refers to the bearer-token owner.", + "responses": { + "200": { + "description": "Current identity and available actions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "linked", + "issuer", + "available", + "can_unlink" + ], + "properties": { + "linked": { + "type": "boolean" + }, + "issuer": { + "type": "string", + "description": "Linked issuer or an empty string." + }, + "available": { + "type": "boolean", + "description": "A valid provider configuration is saved and enabled." + }, + "can_unlink": { + "type": "boolean", + "description": "Whether a non-deleted local password record with a nonempty secret exists; independent of linked." + } + } + } + } + } + }, + "400": { + "description": "Session is unavailable or lacks user access.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Invalid or expired bearer token." + } + } +} diff --git a/backend/schema/paths/oidc/link/post.json b/backend/schema/paths/oidc/link/post.json new file mode 100644 index 0000000000..db0b058f95 --- /dev/null +++ b/backend/schema/paths/oidc/link/post.json @@ -0,0 +1,93 @@ +{ + "tags": [ + "oidc" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "operationId": "startOidcLink", + "summary": "Link OIDC to the current NPM account", + "description": "Requires a valid NPM user session and a saved, enabled provider. No repeated password or recent-login check. The session is revalidated after the provider callback. The identity is matched by issuer and subject, not email.", + "parameters": [ + { + "in": "header", + "name": "Origin", + "required": true, + "description": "Must exactly match the configured public HTTPS origin. Sec-Fetch-Site, if sent, must be same-origin.", + "schema": { + "type": "string", + "format": "uri" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Navigate the browser to this provider authorization URL.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + } + } + }, + "headers": { + "Set-Cookie": { + "description": "Sets __Host-npm_oidc_tx for five minutes: Secure, HttpOnly, Path=/, SameSite=Lax. The callback requires this browser-bound cookie.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid OIDC configuration or provider discovery failure.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Invalid or expired bearer token." + }, + "403": { + "description": "Request is not same-origin JSON." + }, + "404": { + "description": "OIDC is disabled." + }, + "409": { + "description": "The account already has an OIDC link.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/settings/get.json b/backend/schema/paths/oidc/settings/get.json new file mode 100644 index 0000000000..3cd77ae6ef --- /dev/null +++ b/backend/schema/paths/oidc/settings/get.json @@ -0,0 +1,40 @@ +{ + "tags": [ + "oidc" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "operationId": "getOidcSettings", + "summary": "Get OIDC settings", + "description": "Administrator only. The client secret is never returned.", + "responses": { + "200": { + "description": "Current settings and revision.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-settings-object.json" + } + } + } + }, + "400": { + "description": "Permission denied or settings could not be read.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Invalid or expired bearer token." + } + } +} diff --git a/backend/schema/paths/oidc/settings/put.json b/backend/schema/paths/oidc/settings/put.json new file mode 100644 index 0000000000..fd2585648b --- /dev/null +++ b/backend/schema/paths/oidc/settings/put.json @@ -0,0 +1,108 @@ +{ + "tags": [ + "oidc" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "operationId": "updateOidcSettings", + "summary": "Update OIDC settings", + "description": "Administrator only. The enabled configuration is checked by HTTPS discovery before saving. Supply the revision returned by GET; stale updates are rejected.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled", + "auto_login", + "issuer", + "public_url", + "client_id", + "client_secret", + "scopes", + "revision" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "auto_login": { + "type": "boolean" + }, + "issuer": { + "type": "string", + "description": "HTTPS issuer URL; may be empty when disabled.", + "maxLength": 2048 + }, + "public_url": { + "type": "string", + "description": "HTTPS origin of the NPM admin interface; may be empty when disabled.", + "maxLength": 2048 + }, + "client_id": { + "type": "string", + "maxLength": 2048 + }, + "scopes": { + "type": "string", + "description": "Space-separated scopes including openid when enabled.", + "maxLength": 2048 + }, + "token_auth_method": { + "type": "string", + "enum": [ + "auto", + "client_secret_basic", + "client_secret_post" + ] + }, + "revision": { + "type": "string", + "description": "Opaque revision used for optimistic concurrency." + }, + "client_secret": { + "type": "string", + "maxLength": 8192, + "writeOnly": true, + "description": "A nonempty value replaces the secret. An empty string retains the saved secret. A secret is required to enable OIDC." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Saved settings without the client secret.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-settings-object.json" + } + } + } + }, + "400": { + "description": "Invalid fields, discovery failure, or stale revision.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Invalid, expired or revoked bearer token." + }, + "403": { + "description": "Administrator permission required." + } + } +} diff --git a/backend/schema/paths/oidc/start/post.json b/backend/schema/paths/oidc/start/post.json new file mode 100644 index 0000000000..a5fd91a26b --- /dev/null +++ b/backend/schema/paths/oidc/start/post.json @@ -0,0 +1,76 @@ +{ + "tags": [ + "oidc" + ], + "operationId": "startOidcLogin", + "summary": "Start OIDC sign-in", + "description": "Uses Authorization Code with PKCE S256, state and nonce. No bearer token is required.", + "security": [], + "parameters": [ + { + "in": "header", + "name": "Origin", + "required": true, + "description": "Must exactly match the configured public HTTPS origin. Sec-Fetch-Site, if sent, must be same-origin.", + "schema": { + "type": "string", + "format": "uri" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Navigate the browser to this provider authorization URL.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + } + } + }, + "headers": { + "Set-Cookie": { + "description": "Sets __Host-npm_oidc_tx for five minutes: Secure, HttpOnly, Path=/, SameSite=Lax. The callback requires this browser-bound cookie.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid OIDC configuration or provider discovery failure.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "403": { + "description": "Request is not same-origin JSON." + }, + "404": { + "description": "OIDC is disabled." + } + } +} diff --git a/backend/schema/paths/oidc/status/get.json b/backend/schema/paths/oidc/status/get.json new file mode 100644 index 0000000000..931c15e8c8 --- /dev/null +++ b/backend/schema/paths/oidc/status/get.json @@ -0,0 +1,42 @@ +{ + "tags": [ + "oidc" + ], + "operationId": "getOidcStatus", + "summary": "Get public OIDC availability", + "security": [], + "responses": { + "200": { + "description": "OIDC login availability and automatic-login setting.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled", + "auto_login" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "auto_login": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Configuration could not be read.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/unlink/post.json b/backend/schema/paths/oidc/unlink/post.json new file mode 100644 index 0000000000..e4886c25c1 --- /dev/null +++ b/backend/schema/paths/oidc/unlink/post.json @@ -0,0 +1,70 @@ +{ + "tags": [ + "oidc" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "operationId": "unlinkOidcIdentity", + "summary": "Unlink the current user\u2019s OIDC identity", + "description": "Requires a valid NPM user session and a JSON request. Works even after the OIDC provider is disabled or removed. A supplied Sec-Fetch-Site must be same-origin; a supplied Origin must match the request hostname.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OIDC identity removed.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "linked" + ], + "properties": { + "linked": { + "type": "boolean", + "const": false + } + } + } + } + } + }, + "400": { + "description": "Session unavailable or unlink failed.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + }, + "401": { + "description": "Invalid or expired bearer token." + }, + "403": { + "description": "Request is not same-origin JSON." + }, + "409": { + "description": "Cannot remove the account\u2019s last login method.", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/oidc-error.json" + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..a9ce01ecf9 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -60,12 +60,59 @@ "name": "tokens", "description": "Endpoints for managing authentication tokens" }, + { + "name": "oidc", + "description": "OpenID Connect configuration, account linking and sign-in" + }, { "name": "users", "description": "Endpoints for managing users" } ], "paths": { + "/oidc/status": { + "get": { + "$ref": "./paths/oidc/status/get.json" + } + }, + "/oidc/settings": { + "get": { + "$ref": "./paths/oidc/settings/get.json" + }, + "put": { + "$ref": "./paths/oidc/settings/put.json" + } + }, + "/oidc/identity": { + "get": { + "$ref": "./paths/oidc/identity/get.json" + } + }, + "/oidc/unlink": { + "post": { + "$ref": "./paths/oidc/unlink/post.json" + } + }, + "/oidc/start": { + "post": { + "$ref": "./paths/oidc/start/post.json" + } + }, + "/oidc/link": { + "post": { + "$ref": "./paths/oidc/link/post.json" + } + }, + "/oidc/callback": { + "get": { + "$ref": "./paths/oidc/callback/get.json" + } + }, + "/oidc/exchange": { + "post": { + "$ref": "./paths/oidc/exchange/post.json" + } + }, "/": { "get": { "$ref": "./paths/get.json" diff --git a/backend/test/oidc.test.js b/backend/test/oidc.test.js new file mode 100644 index 0000000000..f4468dd0f5 --- /dev/null +++ b/backend/test/oidc.test.js @@ -0,0 +1,459 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { request as httpRequest } from "node:http"; +import { mock, test } from "node:test"; +import express from "express"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import knex from "knex"; + +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, 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, + 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: {} }); + 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.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 () => { + 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("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(); + 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); + }); + 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); + assert.equal((await (await req("identity", { token: regular })).json()).can_unlink, false); + assert.equal((await req("unlink", { token: regular, body: {}, requestOrigin: base })).status, 409); + assert.ok(await database("oidc_identity").where({ user_id: 2 }).first()); + // An administrator can set a local password before the last login method is removed. + await Auth.query().insert({ user_id: 2, type: "password", secret: "restored-password", meta: {} }); + assert.equal((await (await req("identity", { token: regular })).json()).can_unlink, true); + 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"; + }, + ); + for (const enabled of [false, true]) { + await t.test(`provider ${enabled ? "replacement" : "disable"} during callback prevents linking`, async () => { + subject = "provider-change-subject"; + const tx = await start(regular); + const saved = await database("oidc_config").where({ id: 1 }).first(); + const originalQuery = Auth.query; + let changed = false; + // The callback has checked its revision before Access loads this auth row. + // Commit an administrator's update before the identity transaction starts. + Auth.query = function (...args) { + const query = originalQuery.apply(this, args); + const originalFirst = query.first; + query.first = async function (...values) { + const result = await originalFirst.apply(this, values); + if (!changed) { + changed = true; + await database("oidc_config") + .where({ id: 1 }) + .update({ + config: JSON.stringify({ ...JSON.parse(saved.config), enabled }), + revision: "provider-changed-during-callback", + }); + } + return result; + }; + return query; + }; + try { + assert.equal((await callback(tx)).status, 401); + assert.equal(changed, true); + assert.equal(await database("oidc_identity").where({ user_id: 2 }).first(), undefined); + } finally { + Auth.query = originalQuery; + await database("oidc_identity").where({ user_id: 2 }).delete(); + await database("oidc_config") + .where({ id: 1 }) + .update({ config: saved.config, revision: saved.revision }); + subject = "subject-1"; + } + }); + } + await t.test("only a deleted owner's identity can be linked to a replacement account", async () => { + await User.query().insert({ id: 3, email: "replacement@example.com", name: "Replacement", roles: [] }); + await database("user_permission").insert({ user_id: 3, visibility: "user", proxy_hosts: "view" }); + const replacement = (await Token().create({ attrs: { id: 3 }, scope: ["user"], expiresIn: "1h" })).token; + subject = "subject-1"; + for (const disabled of [0, 1]) { + await database("user").where({ id: 1 }).update({ is_disabled: disabled }); + const tx = await start(replacement); + assert.equal((await callback(tx)).status, 401); + assert.ok(await database("oidc_identity").where({ user_id: 1 }).first()); + assert.equal(await database("oidc_identity").where({ user_id: 3 }).first(), undefined); + } + await database("user").where({ id: 1 }).update({ is_deleted: 1 }); + const tx = await start(replacement); + assert.equal((await callback(tx)).status, 303); + assert.equal(await database("oidc_identity").where({ user_id: 1 }).first(), undefined); + assert.equal((await service.linkedUser("https://id.example", subject)).id, 3); + assert.equal((await database("user").where({ id: 3 }).first()).roles, "[]"); + }); + 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/.vitepress/config.mts b/docs/.vitepress/config.mts index b6f6f3f660..0085fc5beb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -79,6 +79,7 @@ export default defineConfig({ items: [ // { text: 'Home', link: '/' }, { text: "Guide", link: "/guide/" }, + { text: "OpenID Connect", link: "/guide/oidc" }, { text: "Screenshots", link: "/screenshots/" }, { text: "Setup Instructions", link: "/setup/" }, { text: "Advanced Configuration", link: "/advanced-config/" }, diff --git a/docs/src/guide/oidc.md b/docs/src/guide/oidc.md new file mode 100644 index 0000000000..bd3db70641 --- /dev/null +++ b/docs/src/guide/oidc.md @@ -0,0 +1,47 @@ +# 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). + +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. +Accounts without a local password cannot unlink their only login method; an +administrator must set a local password first. Deleting an NPM account releases +its identity for an explicit link to another account; disabling it does not. +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.test.ts b/frontend/src/api/backend/oidc.test.ts new file mode 100644 index 0000000000..5ec281ca39 --- /dev/null +++ b/frontend/src/api/backend/oidc.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { getOIDCStatus, oidcRequest, startOIDC } from "./oidc"; + +vi.mock("src/modules/AuthStore", () => ({ default: { token: null } })); +afterEach(() => vi.unstubAllGlobals()); + +test("settings validation retains the actionable public error", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + Response.json({ error: { code: 400, message: "Scopes must include openid" } }, { status: 400 }), + ), + ); + await expect(oidcRequest("settings", "PUT", {})).rejects.toThrow("Scopes must include openid"); +}); + +test("server and protocol failures retain a generic error", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ error: { message: "provider response or internal details" } }, { status: 500 }), + ); + vi.stubGlobal("fetch", fetch); + await expect(oidcRequest("settings", "PUT", {})).rejects.toThrow("Check the settings or sign in locally"); + fetch.mockResolvedValue(Response.json({ error: { message: "provider response" } }, { status: 400 })); + await expect(oidcRequest("start", "POST", {})).rejects.toThrow("Check the settings or sign in locally"); +}); + +test("status and start requests honor the login component cancellation signal", async () => { + const fetch = vi.fn().mockImplementation(() => Promise.resolve(Response.json({ url: "https://id.example" }))); + vi.stubGlobal("fetch", fetch); + const { signal } = new AbortController(); + await getOIDCStatus(signal); + await startOIDC(signal); + for (const call of fetch.mock.calls) expect(call[1].signal).toBe(signal); +}); diff --git a/frontend/src/api/backend/oidc.ts b/frontend/src/api/backend/oidc.ts new file mode 100644 index 0000000000..9586472acc --- /dev/null +++ b/frontend/src/api/backend/oidc.ts @@ -0,0 +1,41 @@ +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
+
+ {identity.issuer}
+