diff --git a/.example.env b/.example.env index f4352af2c..f96d5e9c9 100644 --- a/.example.env +++ b/.example.env @@ -98,3 +98,5 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_SCOPE= OIDC_EMAIL_CLAIM= +OIDC_ROLE_CLAIM=roles +OIDC_ADMIN_GROUP= diff --git a/README.md b/README.md index 565e1565a..ac588e92f 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ You can use files for each of the variables by appending `_FILE` to the name of | `OIDC_CLIENT_SECRET` | OIDC client secret | - | `some-secret` | | `OIDC_SCOPE` | OIDC Scope | `openid profile email` | `openid email` | | `OIDC_EMAIL_CLAIM` | Name of the field to get user's email from | `email` | `userEmail` | +| `OIDC_ROLE_CLAIM` | Name of the claim containing user roles/groups | `roles` | `groups` | +| `OIDC_ADMIN_GROUP` | Group or role value that grants admin privileges. Admin status is updated on every login to stay in sync with IdP | - | `admin` | | `REPORT_EMAIL` | The email address that will receive submitted reports | - | `example@yoursite.com` | | `CONTACT_EMAIL` | The support email address to show on the app | - | `example@yoursite.com` | diff --git a/server/env.js b/server/env.js index 5672823d7..54d94d354 100644 --- a/server/env.js +++ b/server/env.js @@ -68,6 +68,8 @@ const spec = { OIDC_CLIENT_SECRET: str({ default: "" }), OIDC_SCOPE: str({ default: "openid profile email" }), OIDC_EMAIL_CLAIM: str({ default: "email" }), + OIDC_ROLE_CLAIM: str({ default: "roles" }), + OIDC_ADMIN_GROUP: str({ default: "" }), ENABLE_RATE_LIMIT: bool({ default: false }), REPORT_EMAIL: str({ default: "" }), CONTACT_EMAIL: str({ default: "" }), diff --git a/server/handlers/helpers.handler.js b/server/handlers/helpers.handler.js index ff53454b3..5eda85478 100644 --- a/server/handlers/helpers.handler.js +++ b/server/handlers/helpers.handler.js @@ -10,7 +10,7 @@ const env = require("../env"); function error(error, req, res, _next) { if (!(error instanceof CustomError)) { console.error(error); - } else if (env.isDev) { + } else if (process.env.NODE_ENV !== 'production') { console.error(error.message); } diff --git a/server/passport.js b/server/passport.js index 8011b477e..9e84ee5de 100644 --- a/server/passport.js +++ b/server/passport.js @@ -6,7 +6,8 @@ const bcrypt = require("bcryptjs"); const query = require("./queries"); const env = require("./env"); -const utils = require("./utils") +const utils = require("./utils"); +const { ROLES } = require("./consts"); const jwtOptions = { jwtFromRequest: req => req.cookies?.token, @@ -74,6 +75,8 @@ passport.use( }) ); +let oidcInitPromise = null; + if (env.OIDC_ENABLED) { async function enableOIDC() { const requiredKeys = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_SCOPE", "OIDC_EMAIL_CLAIM"]; @@ -108,12 +111,41 @@ if (env.OIDC_ENABLED) { async (req, tokenset, userinfo, done) => { try { const email = userinfo[env.OIDC_EMAIL_CLAIM]; + + // Check if user should be admin based on OIDC claims + // Claims can be in either the ID token or userinfo, check both + let shouldBeAdmin = false; + if (env.OIDC_ADMIN_GROUP) { + const claims = tokenset.claims(); + const roleClaim = claims[env.OIDC_ROLE_CLAIM] || userinfo[env.OIDC_ROLE_CLAIM]; + if (process.env.NODE_ENV !== 'production') { + console.log('OIDC admin check:', { + roleClaim, + expectedGroup: env.OIDC_ADMIN_GROUP, + email + }); + } + if (roleClaim) { + // Handle both array and string claim values + const roles = Array.isArray(roleClaim) ? roleClaim : [roleClaim]; + shouldBeAdmin = roles.includes(env.OIDC_ADMIN_GROUP); + } + } + + const desiredRole = shouldBeAdmin ? ROLES.ADMIN : ROLES.USER; const existingUser = await query.user.find({ email }); - // Existing user. - if (existingUser) return done(null, existingUser); + // Existing user - update role if needed + if (existingUser) { + // Update role on every login to stay in sync with IdP + if (existingUser.role !== desiredRole) { + const updatedUser = await query.user.update({ id: existingUser.id }, { role: desiredRole }); + return done(null, updatedUser); + } + return done(null, existingUser); + } - // New user. + // New user - create with appropriate role // Generate a random password which is not supposed to be used directly. const salt = await bcrypt.genSalt(12); const password = utils.generateRandomPassword(); @@ -125,6 +157,7 @@ if (env.OIDC_ENABLED) { verified: true, verification_token: null, verification_expires: null, + role: desiredRole, }); return done(null, updatedUser); @@ -136,5 +169,7 @@ if (env.OIDC_ENABLED) { ); } - enableOIDC(); + oidcInitPromise = enableOIDC(); } + +module.exports = { oidcInitPromise }; diff --git a/server/server.js b/server/server.js index e1988dc7b..3a778953e 100644 --- a/server/server.js +++ b/server/server.js @@ -25,7 +25,7 @@ if (env.NODE_APP_INSTANCE === 0) { } // intialize passport authentication library -require("./passport"); +const { oidcInitPromise } = require("./passport"); // create express app const app = express(); @@ -86,7 +86,22 @@ app.get("*", renders.notFound); // handle errors coming from above routes app.use(helpers.error); + +// Start server after OIDC initialization (if enabled) +async function startServer() { + if (oidcInitPromise) { + try { + await oidcInitPromise; + console.log("> OIDC initialized successfully"); + } catch (error) { + console.error("Failed to initialize OIDC:", error); + process.exit(1); + } + } -app.listen(env.PORT, () => { - console.log(`> Ready on http://localhost:${env.PORT}`); -}); + app.listen(env.PORT, () => { + console.log(`> Ready on http://localhost:${env.PORT}`); + }); +} + +startServer(); diff --git a/server/utils/utils.js b/server/utils/utils.js index 0203b811f..106474e08 100644 --- a/server/utils/utils.js +++ b/server/utils/utils.js @@ -80,12 +80,14 @@ function addProtocol(url) { } function getSiteURL() { - const protocol = !env.isDev ? "https://" : "http://"; + const isDev = process.env.NODE_ENV !== 'production'; + const protocol = !isDev ? "https://" : "http://"; return `${protocol}${env.DEFAULT_DOMAIN}`; } function getShortURL(address, domain) { - const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://"; + const isDev = process.env.NODE_ENV !== 'production'; + const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !isDev ? "https://" : "http://"; const link = `${domain || env.DEFAULT_DOMAIN}/${address}`; const url = `${protocol}${link}`; return { address, link, url };