diff --git a/backend/internal/oidc.js b/backend/internal/oidc.js new file mode 100644 index 0000000000..40553a29d2 --- /dev/null +++ b/backend/internal/oidc.js @@ -0,0 +1,154 @@ +import { randomUUID } from "node:crypto"; +import errs from "../lib/error.js"; +import { createDefaultAdminUser } from "../lib/default-user.js"; +import { getOIDCConfig } from "../lib/oidc.js"; +import authModel from "../models/auth.js"; +import userModel from "../models/user.js"; +import userPermissionModel from "../models/user_permission.js"; +import { isSetup } from "../setup.js"; +import internalToken from "./token.js"; + +const findUserByEmail = async (email) => { + return userModel + .query() + .where("email", email.toLowerCase().trim()) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); +}; + +const createStandardOidcUser = async ({ email, profile, subject }) => { + const name = profile.name || email; + const nickname = profile.preferred_username || profile.nickname || email.split("@")[0]; + + const user = await userModel.query().insertAndFetch({ + is_deleted: 0, + is_disabled: 0, + email, + name, + nickname, + avatar: "", + roles: [], + }); + + await authModel.query().insert({ + user_id: user.id, + type: "oidc", + secret: randomUUID(), + meta: { + sub: subject, + }, + }); + + await userPermissionModel.query().insert({ + user_id: user.id, + visibility: "user", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", + }); + + return user; +}; + +const ensureOidcAuthMapping = async ({ userId, subject }) => { + if (!subject) { + return; + } + + const existing = await authModel + .query() + .where("user_id", userId) + .where("type", "oidc") + .first(); + + if (existing) { + if (existing.meta?.sub !== subject) { + await authModel + .query() + .patch({ + meta: { + ...(existing.meta || {}), + sub: subject, + }, + }) + .where("id", existing.id); + } + return; + } + + await authModel.query().insert({ + user_id: userId, + type: "oidc", + secret: randomUUID(), + meta: { + sub: subject, + }, + }); +}; + +const getIdentityFromUserInfo = (userinfo) => { + const config = getOIDCConfig(); + const identifier = userinfo?.[config.identifierField] || userinfo?.email; + + if (!identifier || typeof identifier !== "string") { + throw new errs.AuthError("OIDC profile is missing the configured identifier"); + } + + return identifier.toLowerCase().trim(); +}; + +const resolveUser = async (userinfo) => { + const email = getIdentityFromUserInfo(userinfo); + let user = await findUserByEmail(email); + + if (user) { + await ensureOidcAuthMapping({ userId: user.id, subject: userinfo.sub }); + return user; + } + + const setup = await isSetup(); + if (!setup) { + return createDefaultAdminUser({ + email, + authType: "oidc", + authMeta: { + sub: userinfo.sub, + }, + userOverrides: { + name: userinfo.name || "Administrator", + nickname: userinfo.preferred_username || "Admin", + }, + }); + } + + const config = getOIDCConfig(); + if (!config.autoCreateUser) { + throw new errs.AuthError("No account exists for this OIDC user"); + } + + user = await createStandardOidcUser({ + email, + profile: userinfo, + subject: userinfo.sub, + }); + + return user; +}; + +const authenticateFromUserInfo = async (userinfo) => { + const user = await resolveUser(userinfo); + const token = await internalToken.getTokenFromUser(user); + return { + ...token, + auth_method: "oidc", + }; +}; + +export default { + authenticateFromUserInfo, +}; + diff --git a/backend/lib/default-user.js b/backend/lib/default-user.js new file mode 100644 index 0000000000..406434e3d5 --- /dev/null +++ b/backend/lib/default-user.js @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import authModel from "../models/auth.js"; +import userModel from "../models/user.js"; +import userPermissionModel from "../models/user_permission.js"; + +const DEFAULT_ADMIN_PROFILE = { + is_deleted: 0, + is_disabled: 0, + name: "Administrator", + nickname: "Admin", + avatar: "", + roles: ["admin"], +}; + +const DEFAULT_ADMIN_PERMISSIONS = { + visibility: "all", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", +}; + +/** + * @param {string} email + * @param {{name?: string, nickname?: string, avatar?: string}} [overrides] + * @returns {Object} + */ +const buildDefaultAdminUserData = (email, overrides = {}) => { + return { + ...DEFAULT_ADMIN_PROFILE, + email, + ...overrides, + }; +}; + +/** + * @param {number} userId + * @returns {Object} + */ +const buildDefaultAdminPermissions = (userId) => { + return { + user_id: userId, + ...DEFAULT_ADMIN_PERMISSIONS, + }; +}; + +/** + * @param {Object} data + * @param {string} data.email + * @param {string} [data.password] + * @param {string} [data.authType] + * @param {string} [data.authSecret] + * @param {Object} [data.authMeta] + * @param {Object} [data.userOverrides] + * @returns {Promise} + */ +const createDefaultAdminUser = async ({ + email, + password, + authType = "password", + authSecret, + authMeta = {}, + userOverrides = {}, +}) => { + const userData = buildDefaultAdminUserData(email, userOverrides); + const user = await userModel.query().insertAndFetch(userData); + + const secret = + typeof authSecret === "string" + ? authSecret + : typeof password === "string" + ? password + : randomUUID(); + + await authModel.query().insert({ + user_id: user.id, + type: authType, + secret, + meta: authMeta, + }); + + await userPermissionModel.query().insert(buildDefaultAdminPermissions(user.id)); + + return user; +}; + +export { + buildDefaultAdminPermissions, + buildDefaultAdminUserData, + createDefaultAdminUser, +}; + diff --git a/backend/lib/oidc.js b/backend/lib/oidc.js new file mode 100644 index 0000000000..ff2e26812c --- /dev/null +++ b/backend/lib/oidc.js @@ -0,0 +1,209 @@ +import { + authorizationCodeGrant, + buildAuthorizationUrl, + buildEndSessionUrl, + discovery, + fetchUserInfo, + skipSubjectCheck, + allowInsecureRequests, +} from "openid-client"; +import errs from "./error.js"; +import { oidc as logger } from "../logger.js"; + +const DEFAULT_SCOPES = "openid profile email"; +let clientConfigPromise = null; +let oidcConfig = null; + +const toBool = (value) => /^(1|true|yes|on)$/i.test((value || "").trim()); + +const isOIDCEnabled = () => { + return Boolean(process.env.OIDC_ISSUER_URL && process.env.OIDC_CLIENT_ID); +}; + +const parseScopes = () => { + const scopesRaw = process.env.OIDC_SCOPES || DEFAULT_SCOPES; + return scopesRaw + .split(/[\s,]+/) + .map((scope) => scope.trim()) + .filter(Boolean) + .join(" "); +}; + +const getDiscoveryUrl = (issuerUrl) => { + const url = new URL(issuerUrl); + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return url; + } + + const basePath = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; + url.pathname = `${basePath}.well-known/openid-configuration`; + url.search = ""; + url.hash = ""; + return url; +}; + +const getOIDCConfig = () => { + if (oidcConfig) { + return oidcConfig; + } + + if (!isOIDCEnabled()) { + oidcConfig = { enabled: false }; + return oidcConfig; + } + + const redirectUri = process.env.OIDC_REDIRECT_URI; + if (!redirectUri) { + throw new errs.ConfigurationError("OIDC_REDIRECT_URI must be configured when OIDC is enabled"); + } + + const insecureRequestsEnabled = toBool(process.env.OIDC_ALLOW_INSECURE_REQUESTS); + if (insecureRequestsEnabled) { + logger.warn("OIDC_ALLOW_INSECURE_REQUESTS is enabled. Use only in local development."); + } + + // Use separate URLs for internal backend communication and browser-facing URLs + // OIDC_ISSUER_URL_INTERNAL is used for backend discovery and token exchange (can use Docker network hostnames). Defaults to OIDC_ISSUER_URL + // OIDC_ISSUER_URL is used for building authorization URLs sent to the browser (must be accessible from client) + const issuerUrl = process.env.OIDC_ISSUER_URL; + const issuerUrlInternal = process.env.OIDC_ISSUER_URL_INTERNAL || issuerUrl; + + oidcConfig = { + enabled: true, + issuerUrlInternal: issuerUrlInternal, + issuerUrl: issuerUrl, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri, + scopes: parseScopes(), + identifierField: process.env.OIDC_IDENTIFIER_FIELD || "email", + autoCreateUser: process.env.OIDC_AUTO_CREATE_USER === "true", + autoLogin: toBool(process.env.OIDC_AUTO_LOGIN), + logoutRedirectUri: process.env.OIDC_LOGOUT_REDIRECT_URI, + allowInsecureRequests: insecureRequestsEnabled, + }; + return oidcConfig; +}; + +const getOIDCClientConfig = async () => { + const config = getOIDCConfig(); + if (!config.enabled) { + throw new errs.ConfigurationError("OIDC is not configured"); + } + + if (!clientConfigPromise) { + const options = {}; + if (config.allowInsecureRequests) { + options.execute = [allowInsecureRequests]; + } + + const discoveryUrl = getDiscoveryUrl( config.issuerUrlInternal).toString(); + clientConfigPromise = discovery( + new URL(discoveryUrl), + config.clientId, + config.clientSecret, + null, + options, + ).catch((err) => { + throw new errs.ConfigurationError( + `OIDC discovery failed for ${discoveryUrl}: ${err.message}`, + err, + ); + }); + } + + return clientConfigPromise; +}; + +/** + * @param {{ state: string, nonce: string }} params + * @returns {Promise} Authorization URL to redirect the browser to + */ +const buildAuthorizationUrlHelper = async ({ state, nonce }) => { + const clientConfig = await getOIDCClientConfig(); + const config = getOIDCConfig(); + const authorizationUrl = buildAuthorizationUrl(clientConfig, { + redirect_uri: config.redirectUri, + scope: config.scopes, + state, + nonce, + }); + + if (config.issuerUrl && config.issuerUrl !== config.issuerUrlInternal) { + // Replace protocol, host and port + const issuerUrl = new URL(config.issuerUrl); + authorizationUrl.protocol = issuerUrl.protocol; + authorizationUrl.host = issuerUrl.host; + authorizationUrl.port = issuerUrl.port; + } + return authorizationUrl.href; +}; + +/** + * Exchange an authorization code for tokens. + * + * @param {{ callbackUrl: string, expectedState: string, expectedNonce: string }} params + * callbackUrl must be the full URL the browser was redirected to (including query string). + * State and nonce validation is performed internally by authorizationCodeGrant. + * @returns {Promise} + */ +const exchangeAuthorizationCode = async ({ callbackUrl, expectedState, expectedNonce }) => { + const clientConfig = await getOIDCClientConfig(); + return authorizationCodeGrant(clientConfig, new URL(callbackUrl), { + expectedState, + expectedNonce, + idTokenExpected: true, + }); +}; + +/** + * @param {import("openid-client").TokenEndpointResponse} tokens + * @returns {Promise} + */ +const getUserInfo = async (tokens) => { + const clientConfig = await getOIDCClientConfig(); + // skipSubjectCheck: we match users by email, not by sub + return fetchUserInfo(clientConfig, tokens.access_token, skipSubjectCheck); +}; + +/** + * Builds the IdP end-session (logout) URL, or returns null if the IdP does + * not advertise an end_session_endpoint in its discovery document. + * + * @param {{ postLogoutRedirectUri: string, idTokenHint?: string }} params + * @returns {Promise} + */ +const buildIdpLogoutUrl = async ({ postLogoutRedirectUri, idTokenHint }) => { + if (!isOIDCEnabled()) { + return null; + } + const config = getOIDCConfig(); + const clientConfig = await getOIDCClientConfig(); + if (!clientConfig.serverMetadata().end_session_endpoint) { + return null; + } + + const params = { post_logout_redirect_uri: postLogoutRedirectUri }; + if (idTokenHint) { + params.id_token_hint = idTokenHint; + } + + const logoutUrl = buildEndSessionUrl(clientConfig, params); + if (config.issuerUrl && config.issuerUrl !== config.issuerUrlInternal) { + // Replace protocol, host and port + const issuerUrl = new URL(config.issuerUrl); + logoutUrl.protocol = issuerUrl.protocol; + logoutUrl.host = issuerUrl.host; + logoutUrl.port = issuerUrl.port; + } + return logoutUrl.href; +}; + +export { + buildAuthorizationUrlHelper as buildAuthorizationUrl, + buildIdpLogoutUrl, + exchangeAuthorizationCode, + getOIDCConfig, + getUserInfo, + isOIDCEnabled, +}; diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..0f8f342006 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -16,6 +16,7 @@ const importer = new signale.Signale({ scope: "Importer ", ...opts }); const setup = new signale.Signale({ scope: "Setup ", ...opts }); const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +const oidc = new signale.Signale({ scope: "OIDC ", ...opts }); const debug = (logger, ...args) => { if (isDebugMode()) { @@ -23,4 +24,4 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; +export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion, oidc }; diff --git a/backend/package.json b/backend/package.json index e31bf22325..e5772bd36c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "mysql2": "^3.18.2", "node-rsa": "^1.1.1", "objection": "3.1.5", + "openid-client": "6.8.3", "otplib": "^13.3.0", "path": "^0.12.7", "pg": "^8.19.0", diff --git a/backend/routes/main.js b/backend/routes/main.js index 94682cfba4..bd584119f6 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -1,8 +1,10 @@ import express from "express"; import errs from "../lib/error.js"; +import { isOIDCEnabled, getOIDCConfig } from "../lib/oidc.js"; import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.js"; import auditLogRoutes from "./audit-log.js"; +import oidcRoutes from "./oidc.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; import deadHostsRoutes from "./nginx/dead_hosts.js"; @@ -30,9 +32,14 @@ router.get("/", async (_, res /*, next*/) => { const version = pjson.version.split("-").shift().split("."); const setup = await isSetup(); + const oidcEnabled = isOIDCEnabled(); res.status(200).send({ status: "OK", setup, + oidc: { + enabled: oidcEnabled, + autoLogin: oidcEnabled && getOIDCConfig().autoLogin, + }, version: { major: Number.parseInt(version.shift(), 10), minor: Number.parseInt(version.shift(), 10), @@ -42,6 +49,7 @@ router.get("/", async (_, res /*, next*/) => { }); router.use("/schema", schemaRoutes); +router.use("/oidc", oidcRoutes); router.use("/tokens", tokensRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); diff --git a/backend/routes/oidc.js b/backend/routes/oidc.js new file mode 100644 index 0000000000..59f74260e6 --- /dev/null +++ b/backend/routes/oidc.js @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import jwt from "jsonwebtoken"; +import internalOidc from "../internal/oidc.js"; +import { getPrivateKey, getPublicKey } from "../lib/config.js"; +import errs from "../lib/error.js"; +import { + buildAuthorizationUrl, + buildIdpLogoutUrl, + exchangeAuthorizationCode, + getOIDCConfig, + getUserInfo, + isOIDCEnabled, +} from "../lib/oidc.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +const STATE_COOKIE_NAME = "npm_oidc_state"; +const STATE_TOKEN_EXPIRY_SECONDS = 10 * 60; + +const getFirstString = (value) => { + if (Array.isArray(value)) { + return typeof value[0] === "string" ? value[0] : ""; + } + return typeof value === "string" ? value : ""; +}; + +const parseCookies = (cookieHeader = "") => { + return cookieHeader.split(";").reduce((acc, pair) => { + const [rawName, ...rest] = pair.trim().split("="); + if (!rawName || rest.length === 0) { + return acc; + } + acc[rawName] = decodeURIComponent(rest.join("=")); + return acc; + }, {}); +}; + +const setStateCookie = (res, value, secure) => { + const securePart = secure ? "; Secure" : ""; + res.setHeader( + "Set-Cookie", + `${STATE_COOKIE_NAME}=${encodeURIComponent(value)}; Max-Age=${STATE_TOKEN_EXPIRY_SECONDS}; Path=/api/oidc; HttpOnly; SameSite=Lax${securePart}`, + ); +}; + +const clearStateCookie = (res) => { + res.setHeader( + "Set-Cookie", + `${STATE_COOKIE_NAME}=; Max-Age=0; Path=/api/oidc; HttpOnly; SameSite=Lax`, + ); +}; + +const getRequestOrigin = (req) => { + const forwardedProto = getFirstString(req.headers["x-forwarded-proto"]); + const proto = (forwardedProto || req.protocol || "http").split(",")[0].trim(); + const forwardedHost = getFirstString(req.headers["x-forwarded-host"]); + const host = (forwardedHost || req.get("host") || "localhost").split(",")[0].trim(); + + + return `${proto}://${host}`; +}; + +const getSafeRedirectPath = (value) => { + const redirectPath = getFirstString(value) || "/"; + if (!redirectPath.startsWith("/") || redirectPath.startsWith("//")) { + return "/"; + } + return redirectPath; +}; + +const buildStateToken = ({ state, nonce, redirectPath }) => { + return jwt.sign( + { + state, + nonce, + redirectPath, + }, + getPrivateKey(), + { + algorithm: "RS256", + expiresIn: STATE_TOKEN_EXPIRY_SECONDS, + issuer: "oidc", + }, + ); +}; + +const parseStateToken = (token) => { + return jwt.verify(token, getPublicKey(), { + algorithms: ["RS256"], + issuer: "oidc", + }); +}; + +const assertOIDCEnabled = () => { + if (!isOIDCEnabled()) { + throw new errs.ItemNotFoundError(); + } +}; + +router + .route("/login") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + assertOIDCEnabled(); + const state = randomUUID(); + const nonce = randomUUID(); + const redirectPath = getSafeRedirectPath(req.query.redirect_path); + const stateToken = buildStateToken({ state, nonce, redirectPath }); + const authorizationUrl = await buildAuthorizationUrl({ state, nonce }); + setStateCookie(res, stateToken, req.secure); + res.redirect(302, authorizationUrl); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/callback") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + assertOIDCEnabled(); + const cookies = parseCookies(req.headers.cookie || ""); + const stateToken = cookies[STATE_COOKIE_NAME]; + if (!stateToken) { + throw new errs.AuthError("Invalid OIDC state"); + } + + const stateData = parseStateToken(stateToken); + const incomingState = getFirstString(req.query.state); + if (!incomingState || incomingState !== stateData.state) { + throw new errs.AuthError("Invalid OIDC state"); + } + + const config = getOIDCConfig(); + const callbackUrl = new URL(config.redirectUri); + Object.entries(req.query) + .filter(([_, value]) => typeof value === "string") + .forEach(([key, value]) => { + callbackUrl.searchParams.set(key, value); + }); + + const tokenSet = await exchangeAuthorizationCode({ + callbackUrl: callbackUrl.toString(), + expectedState: stateData.state, + expectedNonce: stateData.nonce, + }); + const userinfo = await getUserInfo(tokenSet); + const auth = await internalOidc.authenticateFromUserInfo(userinfo); + + clearStateCookie(res); + + const origin = getRequestOrigin(req); + const redirectUrl = new URL(stateData.redirectPath || "/", origin); + redirectUrl.searchParams.set("oidc_token", auth.token); + redirectUrl.searchParams.set("oidc_expires", auth.expires); + redirectUrl.searchParams.set("oidc_auth_method", "oidc"); + if (tokenSet.id_token) { + redirectUrl.searchParams.set("oidc_id_token", tokenSet.id_token); + } + + res.redirect(302, redirectUrl.toString()); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/logout") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + assertOIDCEnabled(); + clearStateCookie(res); + + const config = getOIDCConfig(); + const origin = getRequestOrigin(req); + const postLogoutRedirectUri = config.logoutRedirectUri || `${origin}/`; + const idTokenHint = getFirstString(req.query.id_token_hint); + + const logoutUrl = await buildIdpLogoutUrl({ postLogoutRedirectUri, idTokenHint }); + if (!logoutUrl) { + res.redirect(302, postLogoutRedirectUri); + return; + } + + res.redirect(302, logoutUrl); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; + diff --git a/backend/schema/components/health-object.json b/backend/schema/components/health-object.json index 592ead2ca4..b0fab7ba85 100644 --- a/backend/schema/components/health-object.json +++ b/backend/schema/components/health-object.json @@ -14,6 +14,19 @@ "description": "Whether the initial setup has been completed", "example": true }, + "oidc": { + "type": "object", + "description": "OIDC availability information", + "additionalProperties": false, + "required": ["enabled"], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether OIDC authentication is configured", + "example": false + } + } + }, "version": { "type": "object", "description": "The version object", diff --git a/backend/schema/paths/get.json b/backend/schema/paths/get.json index 9f6ba2a984..c82e848689 100644 --- a/backend/schema/paths/get.json +++ b/backend/schema/paths/get.json @@ -12,6 +12,9 @@ "value": { "status": "OK", "setup": true, + "oidc": { + "enabled": false + }, "version": { "major": 2, "minor": 1, diff --git a/backend/setup.js b/backend/setup.js index 84f42793ea..3ae404f065 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,11 +1,10 @@ import { installPlugins } from "./lib/certbot.js"; +import { createDefaultAdminUser } from "./lib/default-user.js"; import utils from "./lib/utils.js"; import { setup as logger } from "./logger.js"; -import authModel from "./models/auth.js"; import certificateModel from "./models/certificate.js"; import settingModel from "./models/setting.js"; import userModel from "./models/user.js"; -import userPermissionModel from "./models/user_permission.js"; export const isSetup = async () => { const row = await userModel.query().select("id").where("is_deleted", 0).first(); @@ -35,37 +34,9 @@ const setupDefaultUser = async () => { // Create a new user and set password logger.info(`Creating a new user: ${initialAdminEmail} with password: ${initialAdminPassword}`); - const data = { - is_deleted: 0, + await createDefaultAdminUser({ email: initialAdminEmail, - name: "Administrator", - nickname: "Admin", - avatar: "", - roles: ["admin"], - }; - - const user = await userModel - .query() - .insertAndFetch(data); - - await authModel - .query() - .insert({ - user_id: user.id, - type: "password", - secret: initialAdminPassword, - meta: {}, - }); - - await userPermissionModel.query().insert({ - user_id: user.id, - visibility: "all", - proxy_hosts: "manage", - redirection_hosts: "manage", - dead_hosts: "manage", - streams: "manage", - access_lists: "manage", - certificates: "manage", + password: initialAdminPassword, }); logger.info("Initial admin setup completed"); } diff --git a/backend/yarn.lock b/backend/yarn.lock index 4fbf7eee8a..12b8efd3ca 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1520,6 +1520,11 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jose@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.2.tgz#d6b5279b89b3e88d531c202e3fbe351f39a44aac" + integrity sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ== + js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" @@ -2001,6 +2006,11 @@ npmlog@^6.0.0: gauge "^4.0.3" set-blocking "^2.0.0" +oauth4webapi@^3.8.5: + version "3.8.5" + resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.8.5.tgz#4aa8a73f5c4644daf674a7c40497be910db99d3f" + integrity sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg== + object-inspect@^1.13.3: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" @@ -2034,6 +2044,14 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +openid-client@6.8.3: + version "6.8.3" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-6.8.3.tgz#38aaf86552f1b9dbe1d0fe4a69512e533da14420" + integrity sha512-AoY/NaN9esS3+xvHInFSK0g3skSfeE0uqQAKRj4rB6/GsBIvzwTUaYo9+HcqpKIaP0dP85p5W07hayKgS4GAeA== + dependencies: + jose "^6.2.2" + oauth4webapi "^3.8.5" + otplib@^13.3.0: version "13.3.0" resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.3.0.tgz#2ead040ab29d1a829d1d7c510b059a3e4c76b2b0" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 4d519f8acd..8122c7049c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -42,6 +42,18 @@ services: # Required for DNS Certificate provisioning testing: LE_SERVER: "https://ca.internal/acme/acme/directory" REQUESTS_CA_BUNDLE: "/etc/ssl/certs/NginxProxyManager.crt" + + # Required for OIDC testing: + OIDC_ALLOW_INSECURE_REQUESTS: "true" + # Must use the Docker service hostname from inside the fullstack container for backend discovery + OIDC_ISSUER_URL_INTERNAL: "http://authentik:9000/application/o/npm/" + OIDC_ISSUER_URL: "http://127.0.0.1:9000/application/o/npm/" + OIDC_CLIENT_ID: "7iO2AvuUp9JxiSVkCcjiIbQn4mHmUMBj7yU8EjqU" + OIDC_CLIENT_SECRET: "VUMZzaGTrmXJ8PLksyqzyZ6lrtz04VvejFhPMBP9hGZNCMrn2LLBanySs4ta7XGrDr05xexPyZT1XThaf4ubg00WqvHRVvlu4Naa1aMootNmSRx3VAk6RSslUJmGyHzq" + OIDC_REDIRECT_URI: "http://127.0.0.1:3081/api/oidc/callback" + OIDC_AUTO_CREATE_USER: "true" + # OIDC_AUTO_LOGIN: "true" + volumes: - npm_data:/data - le_data:/etc/letsencrypt diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf index 67efc0f8a9..32646bcc76 100644 --- a/docker/rootfs/etc/nginx/conf.d/dev.conf +++ b/docker/rootfs/etc/nginx/conf.d/dev.conf @@ -13,7 +13,8 @@ server { location /api/ { add_header X-Served-By $host; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; @@ -27,6 +28,7 @@ server { add_header X-Served-By $host; proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header X-Forwarded-Scheme $scheme; diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf index fe2c2f2132..2426de9adf 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf @@ -1,5 +1,6 @@ add_header X-Served-By $host; proxy_set_header Host $host; +proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker/rootfs/etc/nginx/conf.d/production.conf b/docker/rootfs/etc/nginx/conf.d/production.conf index 877e51dda2..7dd2277711 100644 --- a/docker/rootfs/etc/nginx/conf.d/production.conf +++ b/docker/rootfs/etc/nginx/conf.d/production.conf @@ -13,7 +13,8 @@ server { location /api/ { add_header X-Served-By $host; - proxy_set_header Host $host; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index 3ab04ce25b..f2f57145d9 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -248,3 +248,83 @@ On startup, we generate a resolvers directive for Nginx unless this is defined: In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file and then the `/etc/resolv.conf`. + +## OpenID Connect (OIDC) Authentication + +NPM supports Single Sign-On (SSO) via OpenID Connect. When enabled, users can log in using an external identity provider (such as Authentik, Keycloak, Authelia, or any OIDC-compliant provider) instead of local credentials. + +### Prerequisites + +- An OIDC provider with a configured application/client. +- The provider must support the Authorization Code flow. +- A redirect URI pointing to your NPM instance: `http(s)://:/api/oidc/callback`. + +### Environment Variables + +Add the following environment variables to your NPM service: + +```yml +services: + app: + image: 'jc21/nginx-proxy-manager:{{VERSION}}' + environment: + # Required: The OIDC issuer URL (must be accessible from the user's browser) + OIDC_ISSUER_URL: "https://auth.example.com/application/o/npm/" + # Required: Client ID from your OIDC provider + OIDC_CLIENT_ID: "your-client-id" + # Required: Client secret from your OIDC provider + OIDC_CLIENT_SECRET: "your-client-secret" + # Required: Must match the redirect URI configured in your OIDC provider + OIDC_REDIRECT_URI: "https://npm.example.com/api/oidc/callback" + # ... +``` + +| Variable | Required | Description | +|---|---|---| +| `OIDC_ISSUER_URL` | Yes | The OIDC discovery URL as seen by the **browser**. Must be publicly accessible. | +| `OIDC_ISSUER_URL_INTERNAL` | No | An alternative issuer URL used by the **backend** for discovery and token exchange. Useful when NPM runs in Docker and the provider is on the same Docker network (e.g., `http://authentik:9000/application/o/npm/`). Defaults to `OIDC_ISSUER_URL`. | +| `OIDC_CLIENT_ID` | Yes | The client ID assigned by your OIDC provider. | +| `OIDC_CLIENT_SECRET` | Yes | The client secret assigned by your OIDC provider. | +| `OIDC_REDIRECT_URI` | Yes | The callback URL. Must be `http(s)://:/api/oidc/callback` and match what is configured in your provider. | +| `OIDC_SCOPES` | No | Space or comma-separated list of scopes. Defaults to `openid profile email`. | +| `OIDC_IDENTIFIER_FIELD` | No | The claim used to match OIDC users to NPM users. Defaults to `email`. | +| `OIDC_AUTO_CREATE_USER` | No | Set to `true` to automatically create NPM users on first OIDC login. | +| `OIDC_AUTO_LOGIN` | No | Set to `true` to automatically redirect users to the OIDC provider when they visit the login page, skipping the local login form. | +| `OIDC_LOGOUT_REDIRECT_URI` | No | URL to redirect to after OIDC logout. Defaults to the NPM home page. | +| `OIDC_ALLOW_INSECURE_REQUESTS` | No | Set to `true` to allow HTTP (non-TLS) communication with the provider. **Use only for local development.** | + +### Docker Network Setup + +If your OIDC provider runs on the same Docker network, use `OIDC_ISSUER_URL_INTERNAL` to let the backend communicate with the provider directly via the Docker network hostname, while `OIDC_ISSUER_URL` remains the browser-accessible URL: + +```yml + environment: + # Browser-facing URL (accessible from the client machine) + OIDC_ISSUER_URL: "https://auth.example.com/application/o/npm/" + # Internal Docker network URL (used by the backend for discovery & token exchange) + OIDC_ISSUER_URL_INTERNAL: "http://authentik:9000/application/o/npm/" +``` + +### Example: Authentik + +1. In Authentik, create an **OAuth2/OpenID Provider** with: + - **Redirect URI:** `https://npm.example.com/api/oidc/callback` + - **Scopes:** `openid`, `profile`, `email` +2. Create an **Application** linked to that provider. +3. Note the **Client ID** and **Client Secret**. +4. Configure NPM: + +```yml +services: + app: + image: 'jc21/nginx-proxy-manager:{{VERSION}}' + environment: + OIDC_ISSUER_URL: "https://auth.example.com/application/o/npm/" + OIDC_CLIENT_ID: "your-client-id" + OIDC_CLIENT_SECRET: "your-client-secret" + OIDC_REDIRECT_URI: "https://npm.example.com/api/oidc/callback" + OIDC_AUTO_CREATE_USER: "true" + # ... +``` + +Once configured, a **Sign in with OIDC** button will appear on the NPM login page. diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 2f88ede547..f3c2ce30bf 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -4,6 +4,10 @@ export interface HealthResponse { status: string; version: AppVersion; setup: boolean; + oidc?: { + enabled: boolean; + autoLogin: boolean; + }; } export interface TokenResponse { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 34a67ec48d..fd45b6e376 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,4 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; +import { getUnixTime, parseISO } from "date-fns"; import { createContext, type ReactNode, useContext, useState } from "react"; import { useIntervalWhen } from "rooks"; import { @@ -21,6 +22,7 @@ export interface AuthContextType { authenticated: boolean; twoFactorChallenge: TwoFactorChallenge | null; login: (username: string, password: string) => Promise; + completeOidcLogin: (token: string, expires: string, idTokenHint?: string) => void; verifyTwoFactor: (code: string) => Promise; cancelTwoFactor: () => void; loginAs: (id: number) => Promise; @@ -56,6 +58,13 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) handleTokenUpdate(response); }; + const completeOidcLogin = (token: string, expiresIso: string, idTokenHint?: string) => { + const expires = getUnixTime(parseISO(expiresIso)); + AuthStore.set({ token, expires }, { authMethod: "oidc", idTokenHint }); + setAuthenticated(true); + setTwoFactorChallenge(null); + }; + const verifyTwoFactor = async (code: string) => { if (!twoFactorChallenge) { throw new Error("No 2FA challenge pending"); @@ -82,9 +91,17 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) window.location.reload(); return; } + + const authMethod = AuthStore.authMethod; + const idTokenHint = AuthStore.idTokenHint; AuthStore.clear(); setAuthenticated(false); queryClient.clear(); + + if (authMethod === "oidc") { + const query = idTokenHint ? `?id_token_hint=${encodeURIComponent(idTokenHint)}` : ""; + window.location.href = `/api/oidc/logout${query}`; + } }; const refresh = async () => { @@ -106,6 +123,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) authenticated, twoFactorChallenge, login, + completeOidcLogin, verifyTwoFactor, cancelTwoFactor, loginAs, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..394e6cab1f 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -689,6 +689,9 @@ "sign-in": { "defaultMessage": "Sign in" }, + "sign-in.oidc": { + "defaultMessage": "Sign in with OIDC" + }, "ssl-certificate": { "defaultMessage": "SSL Certificate" }, diff --git a/frontend/src/locale/src/es.json b/frontend/src/locale/src/es.json index c8b1edb075..228d28377e 100644 --- a/frontend/src/locale/src/es.json +++ b/frontend/src/locale/src/es.json @@ -611,6 +611,9 @@ "sign-in": { "defaultMessage": "Iniciar Sesión" }, + "sign-in.oidc": { + "defaultMessage": "Iniciar Sesión con OIDC" + }, "ssl-certificate": { "defaultMessage": "Certificado SSL" }, diff --git a/frontend/src/modules/AuthStore.ts b/frontend/src/modules/AuthStore.ts index 9978aaa2f6..b6bb338f69 100644 --- a/frontend/src/modules/AuthStore.ts +++ b/frontend/src/modules/AuthStore.ts @@ -3,11 +3,27 @@ import type { TokenResponse } from "src/api/backend"; export const TOKEN_KEY = "authentications"; +type AuthMethod = "local" | "oidc"; + +interface StoredToken extends Omit { + // It may come as ISO string or unix epoch + expires: number | string; + authMethod?: AuthMethod; + idTokenHint?: string; +} + +const toUnixTimestamp = (expires: number | string): number => { + if (typeof expires === "number") { + return expires; + } + return getUnixTime(parseISO(expires)); +}; + export class AuthStore { // Get all tokens from stack get tokens() { const t = localStorage.getItem(TOKEN_KEY); - let tokens = []; + let tokens: StoredToken[] = []; if (t !== null) { try { tokens = JSON.parse(t); @@ -27,12 +43,20 @@ export class AuthStore { return null; } - // Get expires from last token + get authMethod() { + return this.token?.authMethod || "local"; + } + + get idTokenHint() { + return this.token?.idTokenHint || null; + } + + // Get expires from last token (as unix timestamp) get expires() { const t = this.token; if (t && typeof t.expires !== "undefined") { - const expires = Number(t.expires); - if (expires && !Number.isNaN(expires)) { + const expires = toUnixTimestamp(t.expires); + if (!Number.isNaN(expires)) { return expires; } } @@ -54,7 +78,7 @@ export class AuthStore { const now = Math.round(Date.now() / 1000); const oneMinuteBuffer = 60; for (let i = t.length - 1; i >= 0; i--) { - const dte = getUnixTime(parseISO(t[i].expires)); + const dte = toUnixTimestamp(t[i].expires); const valid = dte - oneMinuteBuffer > now; if (valid) { return true; @@ -65,14 +89,24 @@ export class AuthStore { } // Set a single token on the stack - set({ token, expires }: TokenResponse) { - localStorage.setItem(TOKEN_KEY, JSON.stringify([{ token, expires }])); + set({ token, expires }: TokenResponse, options: { authMethod?: AuthMethod; idTokenHint?: string } = {}) { + localStorage.setItem( + TOKEN_KEY, + JSON.stringify([ + { + token, + expires, + authMethod: options.authMethod || "local", + idTokenHint: options.idTokenHint, + }, + ]), + ); } // Add a token to the END of the stack - add({ token, expires }: TokenResponse) { + add({ token, expires }: TokenResponse, options: { authMethod?: AuthMethod; idTokenHint?: string } = {}) { const t = this.tokens; - t.push({ token, expires }); + t.push({ token, expires, authMethod: options.authMethod || "local", idTokenHint: options.idTokenHint }); localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); } diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index ebf7eeb376..a9f93a3ded 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -80,6 +80,7 @@ function TwoFactorForm() { function LoginForm() { const emailRef = useRef(null); const [formErr, setFormErr] = useState(""); + const health = useHealth(); const { login } = useAuthState(); const onSubmit = async (values: any, { setSubmitting }: any) => { @@ -159,6 +160,19 @@ function LoginForm() { + {health.data?.oidc?.enabled && ( +
+ +
+ )} )} @@ -167,9 +181,37 @@ function LoginForm() { } export default function Login() { - const { twoFactorChallenge } = useAuthState(); + const { twoFactorChallenge, completeOidcLogin } = useAuthState(); const health = useHealth(); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get("oidc_token"); + const expires = params.get("oidc_expires"); + if (!token || !expires) { + return; + } + + completeOidcLogin(token, expires, params.get("oidc_id_token") || undefined); + params.delete("oidc_token"); + params.delete("oidc_expires"); + params.delete("oidc_auth_method"); + params.delete("oidc_id_token"); + const nextSearch = params.toString(); + window.history.replaceState({}, document.title, `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`); + }, [completeOidcLogin]); + + useEffect(() => { + console.log(health.data) + if ( + health.data?.oidc?.enabled && + health.data?.oidc?.autoLogin && + !new URLSearchParams(window.location.search).has("oidc_token") + ) { + window.location.href = `/api/oidc/login?redirect_path=${encodeURIComponent(window.location.pathname)}`; + } + }, [health.data]); + const getVersion = () => { if (!health.data) { return "";