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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions backend/internal/oidc.js
Original file line number Diff line number Diff line change
@@ -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 };
}
112 changes: 112 additions & 0 deletions backend/lib/oidc.js
Original file line number Diff line number Diff line change
@@ -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 };
19 changes: 19 additions & 0 deletions backend/migrations/20260918000000_oidc.js
Original file line number Diff line number Diff line change
@@ -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");
}
6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,17 +23,20 @@
"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",
"jsonwebtoken": "^9.0.3",
"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",
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading