Skip to content
Closed
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
154 changes: 154 additions & 0 deletions backend/internal/oidc.js
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +93 to +106
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OIDC_IDENTIFIER_FIELD is treated as the value to look up (and potentially create) the user’s email, but it can be configured to any claim. If a non-email claim is chosen (e.g. preferred_username), this will store a non-email string in the email column and break assumptions elsewhere (UI validation, uniqueness, notifications, etc.). Consider either (1) validating that the resolved identifier is a valid email address and erroring otherwise, or (2) renaming/limiting the config so it’s clearly an email claim (e.g. OIDC_EMAIL_CLAIM).

Copilot uses AI. Check for mistakes.

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,
};

94 changes: 94 additions & 0 deletions backend/lib/default-user.js
Original file line number Diff line number Diff line change
@@ -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<Object>}
*/
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,
};

Loading
Loading