From b2a5a17ef6a3568b4ad3281047443642e9c3e349 Mon Sep 17 00:00:00 2001 From: giutrec Date: Sat, 2 May 2026 18:03:50 +0200 Subject: [PATCH] feat: Add SSO authentication via OAuth2/OIDC with RBAC support --- backend/internal/sso.js | 167 ++++++++++++++++++++ backend/package.json | 1 + backend/routes/main.js | 2 + backend/routes/sso.js | 100 ++++++++++++ backend/yarn.lock | 25 +++ docker/docker-compose.ci.yml | 7 + docker/docker-compose.dev.yml | 12 +- frontend/src/Router.tsx | 15 ++ frontend/src/api/backend/getSsoProviders.ts | 9 ++ frontend/src/api/backend/index.ts | 1 + frontend/src/pages/Login/index.tsx | 18 +++ frontend/src/pages/SSOSuccess/index.tsx | 3 + 12 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 backend/internal/sso.js create mode 100644 backend/routes/sso.js create mode 100644 frontend/src/api/backend/getSsoProviders.ts create mode 100644 frontend/src/pages/SSOSuccess/index.tsx diff --git a/backend/internal/sso.js b/backend/internal/sso.js new file mode 100644 index 0000000000..f6fa02b497 --- /dev/null +++ b/backend/internal/sso.js @@ -0,0 +1,167 @@ +import { Issuer, generators } from "openid-client"; +import errs from "../lib/error.js"; +import internalToken from "./token.js"; +import internalUser from "./user.js"; +import userModel from "../models/user.js"; +import userPermissionModel from "../models/user_permission.js"; +import { debug, express as logger } from "../logger.js"; + +const getConfig = () => { + const clientId = process.env.OIDC_CLIENT_ID; + const clientSecret = process.env.OIDC_CLIENT_SECRET; + const issuerUrl = process.env.OIDC_ISSUER_URL; + const redirectUri = process.env.OIDC_REDIRECT_URI; + const autoCreateUser = process.env.OIDC_AUTO_CREATE_USER !== "false"; + const groupsClaim = process.env.OIDC_GROUPS_CLAIM || "groups"; + const groupAdmin = process.env.OIDC_GROUP_ADMIN; + const groupUser = process.env.OIDC_GROUP_USER; + + if (!clientId || !clientSecret || !issuerUrl || !redirectUri) { + return null; + } + + return { + clientId, + clientSecret, + issuerUrl, + redirectUri, + autoCreateUser, + groupsClaim, + groupAdmin, + groupUser, + }; +}; + +let _client = null; + +const getClient = async () => { + if (_client) return _client; + const config = getConfig(); + if (!config) return null; + + try { + const issuer = await Issuer.discover(config.issuerUrl); + _client = new issuer.Client({ + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uris: [config.redirectUri], + response_types: ["code"], + }); + return _client; + } catch (err) { + logger.error("OIDC Discover failed: " + err.message); + return null; + } +}; + +export default { + isConfigured: () => { + return getConfig() !== null; + }, + + getAuthorizationUrl: async () => { + const client = await getClient(); + if (!client) throw new Error("OIDC not configured"); + + const state = generators.state(); + const nonce = generators.nonce(); + + const url = client.authorizationUrl({ + scope: "openid email profile", + state, + nonce, + }); + + return { url, state, nonce }; + }, + + handleCallback: async (reqUrl, state, nonce) => { + const client = await getClient(); + if (!client) throw new Error("OIDC not configured"); + const config = getConfig(); + + const params = client.callbackParams(reqUrl); + const tokenSet = await client.callback(config.redirectUri, params, { state, nonce }); + + const claims = tokenSet.claims(); + const email = claims.email || claims.preferred_username; + + if (!email) { + throw new errs.AuthError("No email or preferred_username provided by OIDC"); + } + + let isAdmin = false; + if (config.groupAdmin || config.groupUser) { + const groups = claims[config.groupsClaim] || []; + const groupsArr = Array.isArray(groups) ? groups : [groups]; + isAdmin = config.groupAdmin && groupsArr.includes(config.groupAdmin); + const isUser = config.groupUser && groupsArr.includes(config.groupUser); + + if (!isAdmin && !isUser) { + throw new errs.AuthError("User does not have access based on OIDC groups"); + } + } + + // Find user + let user = await userModel + .query() + .where("email", email.toLowerCase().trim()) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + if (!config.autoCreateUser) { + throw new errs.AuthError("User does not exist and auto-create is disabled"); + } + + // auto create user + const mockAccess = { + can: async () => true, // bypass permission checks for internal creation + token: { + getUserId: () => 0, + hasScope: () => true + } + }; + user = await internalUser.create(mockAccess, { + name: claims.name || claims.given_name || email, + nickname: claims.nickname || email.split("@")[0], + email: email, + roles: isAdmin ? ["admin"] : [], + is_disabled: 0, + }); + } else { + // Sync roles if RBAC is enabled + if (config.groupAdmin || config.groupUser) { + const currentIsAdmin = user.roles && user.roles.includes("admin"); + if (currentIsAdmin !== isAdmin) { + user = await userModel.query().patchAndFetchById(user.id, { + roles: isAdmin ? ["admin"] : [] + }); + + const existingPerms = await userPermissionModel.query().where("user_id", user.id).first(); + if (existingPerms) { + await userPermissionModel.query().patchAndFetchById(existingPerms.id, { + visibility: isAdmin ? "all" : "user" + }); + } else { + await userPermissionModel.query().insert({ + user_id: user.id, + visibility: isAdmin ? "all" : "user", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", + }); + } + } + } + } + + // Generate token + const tokenInfo = await internalToken.getTokenFromUser(user); + return tokenInfo; + }, +}; diff --git a/backend/package.json b/backend/package.json index e31bf22325..0a3b9775c2 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": "^5.6.5", "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..f86d11726f 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -15,6 +15,7 @@ import settingsRoutes from "./settings.js"; import tokensRoutes from "./tokens.js"; import usersRoutes from "./users.js"; import versionRoutes from "./version.js"; +import ssoRoutes from "./sso.js"; const router = express.Router({ caseSensitive: true, @@ -42,6 +43,7 @@ router.get("/", async (_, res /*, next*/) => { }); router.use("/schema", schemaRoutes); +router.use("/sso", ssoRoutes); router.use("/tokens", tokensRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); diff --git a/backend/routes/sso.js b/backend/routes/sso.js new file mode 100644 index 0000000000..50f499ac08 --- /dev/null +++ b/backend/routes/sso.js @@ -0,0 +1,100 @@ +import express from "express"; +import internalSso from "../internal/sso.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router + .route("/providers") + .options((_, res) => { + res.sendStatus(204); + }) + /** + * GET /sso/providers + * + * Returns whether SSO is configured + */ + .get((req, res, next) => { + try { + res.status(200).send({ + oidc: internalSso.isConfigured() + }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/login") + .options((_, res) => { + res.sendStatus(204); + }) + /** + * GET /sso/login + * + * Redirects to the OIDC provider + */ + .get(async (req, res, next) => { + try { + if (!internalSso.isConfigured()) { + return res.status(400).send("SSO not configured"); + } + + const { url, state, nonce } = await internalSso.getAuthorizationUrl(); + + // Store state and nonce in cookies for validation in callback + res.cookie("oidc_state", state, { httpOnly: true, maxAge: 15 * 60 * 1000 }); + res.cookie("oidc_nonce", nonce, { httpOnly: true, maxAge: 15 * 60 * 1000 }); + + res.redirect(url); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/callback") + .options((_, res) => { + res.sendStatus(204); + }) + /** + * GET /sso/callback + * + * Handles callback from the OIDC provider + */ + .get(async (req, res, next) => { + try { + if (!internalSso.isConfigured()) { + return res.status(400).send("SSO not configured"); + } + + const state = req.headers.cookie?.split('; ').find(row => row.startsWith('oidc_state='))?.split('=')[1]; + const nonce = req.headers.cookie?.split('; ').find(row => row.startsWith('oidc_nonce='))?.split('=')[1]; + + if (!state || !nonce) { + return res.status(400).send("Missing state or nonce from cookies"); + } + + // Clear cookies + res.clearCookie("oidc_state"); + res.clearCookie("oidc_nonce"); + + const fullUrl = req.protocol + "://" + req.get("host") + req.originalUrl; + const tokenInfo = await internalSso.handleCallback(fullUrl, state, nonce); + + // Redirect to frontend with the token + res.redirect(`/?sso_token=${tokenInfo.token}&sso_expires=${encodeURIComponent(tokenInfo.expires)}`); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + // Redirect to login with error + res.redirect(`/?error=${encodeURIComponent(err.message)}`); + } + }); + +export default router; diff --git a/backend/yarn.lock b/backend/yarn.lock index 4fbf7eee8a..b29865607a 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1520,6 +1520,11 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jose@^4.15.9: + version "4.15.9" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" + integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== + 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" +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + object-inspect@^1.13.3: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" @@ -2015,6 +2025,11 @@ objection@3.1.5: ajv-formats "^2.1.1" db-errors "^0.2.3" +oidc-token-hash@^5.0.3: + version "5.2.0" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz#be8a8885c7e2478d21a674e15afa31f1bcc4a61f" + integrity sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw== + on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -2034,6 +2049,16 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +openid-client@^5.6.5: + version "5.7.1" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.7.1.tgz#34cace862a3e6472ed7d0a8616ef73b7fb85a9c3" + integrity sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew== + dependencies: + jose "^4.15.9" + lru-cache "^6.0.0" + object-hash "^2.2.0" + oidc-token-hash "^5.0.3" + 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.ci.yml b/docker/docker-compose.ci.yml index 1bf6dade2d..1c25c9525b 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -13,6 +13,13 @@ services: # Required for DNS Certificate provisioning in CI LE_SERVER: "https://ca.internal/acme/acme/directory" REQUESTS_CA_BUNDLE: "/etc/ssl/certs/NginxProxyManager.crt" + # OIDC_CLIENT_ID: "your_client_id_here" + # OIDC_CLIENT_SECRET: "your_client_secret_here" + # OIDC_ISSUER_URL: "http://authentik:9000/application/o/npm/" + # OIDC_REDIRECT_URI: "http://127.0.0.1:3081/api/sso/callback" + # OIDC_GROUPS_CLAIM: "groups" + # OIDC_GROUP_ADMIN: "sysadmins" + # OIDC_GROUP_USER: "user" volumes: - "npm_data_ci:/data" - "npm_le_ci:/etc/letsencrypt" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 4d519f8acd..5aa179b0f4 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -42,6 +42,14 @@ services: # Required for DNS Certificate provisioning testing: LE_SERVER: "https://ca.internal/acme/acme/directory" REQUESTS_CA_BUNDLE: "/etc/ssl/certs/NginxProxyManager.crt" + # OIDC_CLIENT_ID: "your_client_id_here" + # OIDC_CLIENT_SECRET: "your_client_secret_here" + # OIDC_ISSUER_URL: "http://authentik:9000/application/o/npm/" + # OIDC_REDIRECT_URI: "http://127.0.0.1:3081/api/sso/callback" + # OIDC_GROUPS_CLAIM: "groups" + # OIDC_GROUP_ADMIN: "sysadmins" + # OIDC_GROUP_USER: "user" + volumes: - npm_data:/data - le_data:/etc/letsencrypt @@ -50,7 +58,7 @@ services: - ../frontend:/frontend - "/etc/localtime:/etc/localtime:ro" healthcheck: - test: ["CMD", "/usr/bin/check-health"] + test: [ "CMD", "/usr/bin/check-health" ] interval: 10s timeout: 3s depends_on: @@ -204,7 +212,7 @@ services: - nginx_proxy_manager restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + test: [ "CMD-SHELL", "redis-cli ping | grep PONG" ] start_period: 20s interval: 30s retries: 5 diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..eb7eb0ece9 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -26,10 +26,25 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts")); const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts")); const Streams = lazy(() => import("src/pages/Nginx/Streams")); +import AuthStore from "src/modules/AuthStore"; + function Router() { const health = useHealth(); const { authenticated } = useAuthState(); + if (typeof window !== "undefined") { + const searchParams = new URLSearchParams(window.location.search); + const ssoToken = searchParams.get("sso_token"); + const ssoExpires = searchParams.get("sso_expires"); + + if (ssoToken && ssoExpires) { + AuthStore.set({ token: ssoToken, expires: ssoExpires as unknown as number }); + window.history.replaceState({}, document.title, "/"); + window.location.reload(); + return ; + } + } + if (health.isLoading) { return ; } diff --git a/frontend/src/api/backend/getSsoProviders.ts b/frontend/src/api/backend/getSsoProviders.ts new file mode 100644 index 0000000000..d8325f00a4 --- /dev/null +++ b/frontend/src/api/backend/getSsoProviders.ts @@ -0,0 +1,9 @@ +import { get } from "./base"; + +export interface SsoProvidersResponse { + oidc: boolean; +} + +export function getSsoProviders(): Promise { + return get({ url: "/sso/providers" }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..2aa16fa873 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -37,6 +37,7 @@ export * from "./getStreams"; export * from "./getToken"; export * from "./getUser"; export * from "./getUsers"; +export * from "./getSsoProviders"; export * from "./helpers"; export * from "./loginAsUser"; export * from "./models"; diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index ebf7eeb376..5b3f201743 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from "react"; import Alert from "react-bootstrap/Alert"; import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; +import { useQuery } from "@tanstack/react-query"; +import { getSsoProviders } from "src/api/backend"; import { useHealth } from "src/hooks"; import { intl, T } from "src/locale"; import { validateEmail, validateString } from "src/modules/Validations"; @@ -81,6 +83,7 @@ function LoginForm() { const emailRef = useRef(null); const [formErr, setFormErr] = useState(""); const { login } = useAuthState(); + const { data: ssoProviders } = useQuery({ queryKey: ["sso-providers"], queryFn: getSsoProviders }); const onSubmit = async (values: any, { setSubmitting }: any) => { setFormErr(""); @@ -162,6 +165,21 @@ function LoginForm() { )} + {ssoProviders?.oidc && ( +
+
or
+
+ +
+
+ )} ); } diff --git a/frontend/src/pages/SSOSuccess/index.tsx b/frontend/src/pages/SSOSuccess/index.tsx new file mode 100644 index 0000000000..fb4181b1a5 --- /dev/null +++ b/frontend/src/pages/SSOSuccess/index.tsx @@ -0,0 +1,3 @@ +export default function SSOSuccess() { + return null; +}