Add OIDC login and per-domain authorization

Authentication previously implied full authorization: any valid token
could manage every host, DNS provider, domain, and user. This adds SSO
login and a per-domain rights model.

OIDC login (authorization_code + PKCE):
- conf.oidc + conf.auth blocks; clientSecret in (gitignored) secrets.js.
- utils/oidc.js (state/PKCE, code exchange, userinfo) using global fetch.
- models/oidc_state.js: short-lived state store, auto-expiring via
  model-redis 1.5 per-key TTL.
- routes/auth.js: GET /auth/oidc/start + /auth/oidc/callback; JIT-provisions
  a local user, mints an AuthToken carrying the SSO groups, hands the token to
  the browser via a URL fragment. "Log in with SSO" button on the login page.

Authorization (groups + app overrides, per-domain, with ownership):
- models/grant.js + utils/roles.js (pure, unit-tested): effective rights from
  conf.auth (admin users/groups, group->role map), Grant records
  (user|group -> global|domain -> viewer|manager|admin), and ownership
  (created_by). Roles rank admin > manager(owner) > viewer.
- AuthToken stores session groups; middleware/auth.js exposes req.groups.
- middleware/authz.js: requireAdmin, requireDomainRole(minRole, resolveDomain),
  filterViewable. Applied across routes: host mutations need manager on the
  host's domain; reads are filtered to visible domains; DNS providers, user
  management, and grant management are global-admin-only; certs need viewer.
- routes/grant.js: admin CRUD for grants. Anti-lockout via conf.auth.adminUsers
  plus migrations/grant_bootstrap.js.

Frontend: /me returns effective rights; nav gates Users/Grants to admins;
grants management page; OIDC token-fragment handling in app-base.js.

Tests: utils/roles and utils/oidc unit-tested (no redis); wired into the test
scripts. Full suite 89 pass. Also verified end-to-end against redis (grant
resolution, middleware allow/deny/403, list filtering) and the OIDC pure flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 12:17:05 -04:00
parent 9f175f5bf6
commit 10abd36340
28 changed files with 1317 additions and 50 deletions
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
/**
* Minimal OpenID Connect authorization-code + PKCE client.
*
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
* instead we treat the flow as opaque and read identity from the userinfo
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
* global fetch (Node 18+) and crypto — no external dependency.
*
* All endpoints and client config come from conf.oidc (+ clientSecret from
* secrets.js, deep-merged by @simpleworkjs/conf).
*/
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// A high-entropy random string for `state` / PKCE verifier.
function randomToken(bytes = 32){
return base64url(crypto.randomBytes(bytes));
}
// PKCE S256 challenge derived from the verifier.
function codeChallengeS256(verifier){
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
function createAuthRequest(){
let state = randomToken(32);
let codeVerifier = randomToken(32);
let codeChallenge = codeChallengeS256(codeVerifier);
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to.
function buildAuthUrl(state, codeChallenge){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint.
async function exchangeCode(code, codeVerifier){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
let res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if(!res.ok){
let text = await res.text().catch(() => '');
let error = new Error('OidcTokenExchangeFailed');
error.name = 'OidcTokenExchangeFailed';
error.message = `Token exchange failed (${res.status}): ${text}`;
error.status = 502;
throw error;
}
return res.json();
}
// Fetch the userinfo claims for an access token.
async function fetchUserInfo(accessToken){
let o = conf.oidc;
let res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if(!res.ok){
let error = new Error('OidcUserInfoFailed');
error.name = 'OidcUserInfoFailed';
error.message = `Userinfo request failed (${res.status})`;
error.status = 502;
throw error;
}
return res.json();
}
// Pull the app username and group list out of userinfo claims per conf.
function claimsToIdentity(claims){
let o = conf.oidc;
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
let groups = claims[o.groupsClaim || 'groups'] || [];
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
return {username, groups, claims};
}
module.exports = {
randomToken,
codeChallengeS256,
createAuthRequest,
buildAuthUrl,
exchangeCode,
fetchUserInfo,
claimsToIdentity,
};
+110
View File
@@ -0,0 +1,110 @@
'use strict';
/**
* Pure authorization role logic — no redis, no I/O — so it can be unit tested
* in isolation. models/grant.js supplies the data (grant records, owned
* domains, conf.auth) and this module collapses it into effective rights and
* answers allow/deny questions.
*
* Roles rank: admin > manager (owner/full over a domain) > viewer.
*/
const ROLE_RANK = {viewer: 1, manager: 2, admin: 3};
function rank(role){
return ROLE_RANK[role] || 0;
}
// Whichever of two roles is stronger; either may be null/undefined.
function maxRole(a, b){
if(rank(a) >= rank(b)) return a || b || null;
return b || a || null;
}
/**
* Collapse config, grants, and ownership into effective rights.
*
* @param {Object} identity - {username, groups: string[]}
* @param {Object} data
* - grants: [{subjectType, subject, scope, domain, role}]
* - ownedDomains: string[] (domains the user owns via created_by)
* - authConf: conf.auth ({adminUsers, adminGroups, groupRoleMap})
* @returns {Object} { isAdmin, global: role|null, domains: {domain: role} }
*/
function resolveEffective(identity, data){
let username = identity && identity.username;
let groups = (identity && identity.groups) || [];
let grants = (data && data.grants) || [];
let ownedDomains = (data && data.ownedDomains) || [];
let authConf = (data && data.authConf) || {};
let result = {isAdmin: false, global: null, domains: {}};
// 1) Config-driven global admin (anti-lockout bootstrap).
if((authConf.adminUsers || []).includes(username)) result.isAdmin = true;
for(let g of groups){
if((authConf.adminGroups || []).includes(g)) result.isAdmin = true;
}
// 2) Config-driven group role defaults.
let groupRoleMap = authConf.groupRoleMap || {};
for(let g of groups){
let m = groupRoleMap[g];
if(!m) continue;
if(m.role === 'admin' && (m.scope === 'global' || !m.scope)){
result.isAdmin = true;
}else if(m.scope === 'global'){
result.global = maxRole(result.global, m.role);
}else if(m.domain){
result.domains[m.domain] = maxRole(result.domains[m.domain], m.role);
}
}
// 3) Grant records for this user or any of their groups.
for(let grant of grants){
let matches = (grant.subjectType === 'user' && grant.subject === username)
|| (grant.subjectType === 'group' && groups.includes(grant.subject));
if(!matches) continue;
if(grant.scope === 'global'){
if(grant.role === 'admin') result.isAdmin = true;
else result.global = maxRole(result.global, grant.role);
}else{
result.domains[grant.domain] = maxRole(result.domains[grant.domain], grant.role);
}
}
// 4) Ownership: manager rights over every owned domain.
for(let domain of ownedDomains){
result.domains[domain] = maxRole(result.domains[domain], 'manager');
}
return result;
}
// Effective role on one domain, folding in admin and any global role.
function roleForDomain(effective, domain){
if(effective.isAdmin) return 'admin';
return maxRole(effective.global, effective.domains[domain]);
}
// Does `effective` meet or exceed `minRole` for `domain`?
function allows(effective, minRole, domain){
return rank(roleForDomain(effective, domain)) >= rank(minRole);
}
// Domain names the identity can at least view (excludes the global-role case,
// which callers treat as "sees everything").
function visibleDomains(effective){
return Object.keys(effective.domains).filter(d => rank(effective.domains[d]) >= rank('viewer'));
}
module.exports = {
ROLE_RANK,
rank,
maxRole,
resolveEffective,
roleForDomain,
allows,
visibleDomains,
};