Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions backend/internal/sso.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
100 changes: 100 additions & 0 deletions backend/routes/sso.js
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions backend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions docker/docker-compose.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading