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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user