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:
@@ -3,14 +3,18 @@
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const middleware = require('../middleware/auth');
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
// API routes for authentication.
|
||||
// API routes for authentication.
|
||||
router.use('/auth', require('./auth'));
|
||||
|
||||
// API routes for working with users. All endpoints need to be have valid user.
|
||||
// User management is admin-only; the router allows self-service exceptions
|
||||
// (GET /me, PUT /password) before its own admin gate.
|
||||
router.use('/user', middleware.auth, require('./user'));
|
||||
|
||||
// API routes for working with hosts. All endpoints need to be have valid user.
|
||||
// Per-domain authorization is enforced inside the host router.
|
||||
router.use('/host', middleware.auth, require('./host'));
|
||||
|
||||
router.use('/dns', middleware.auth, require('./dns'));
|
||||
@@ -18,4 +22,7 @@ router.use('/dns', middleware.auth, require('./dns'));
|
||||
// API routes for working with hosts. All endpoints need to be have valid user.
|
||||
router.use('/cert', middleware.auth, require('./cert'));
|
||||
|
||||
// Grant management (who can manage which domains) is global-admin-only.
|
||||
router.use('/grant', middleware.auth, authz.requireAdmin, require('./grant'));
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,7 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Auth } = require('../models/auth');
|
||||
const { OidcState } = require('../models/oidc_state');
|
||||
const oidc = require('../utils/oidc');
|
||||
|
||||
|
||||
router.post('/login', async function(req, res, next){
|
||||
@@ -29,4 +32,66 @@ router.all('/logout', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
|
||||
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
|
||||
*/
|
||||
router.get('/oidc/start', async function(req, res, next){
|
||||
try{
|
||||
if(!conf.oidc || !conf.oidc.enabled){
|
||||
let error = new Error('OidcDisabled');
|
||||
error.status = 404;
|
||||
error.message = 'OIDC login is not enabled.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
|
||||
await OidcState.create({
|
||||
state,
|
||||
codeVerifier,
|
||||
redirect: req.query.redirect || '/',
|
||||
});
|
||||
|
||||
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC callback: validate state (consuming the one-time record), exchange the
|
||||
* code for tokens, read identity from userinfo, establish a session, and hand
|
||||
* the app token back to the browser via a URL fragment for the login page to
|
||||
* store in localStorage.
|
||||
*/
|
||||
router.get('/oidc/callback', async function(req, res, next){
|
||||
try{
|
||||
let {code, state} = req.query;
|
||||
if(!code || !state){
|
||||
let error = new Error('OidcCallbackInvalid');
|
||||
error.status = 400;
|
||||
error.message = 'Missing code or state.';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// get() throws if the state is unknown or has expired — this both binds
|
||||
// the callback to our request and bounds replay.
|
||||
let saved = await OidcState.get(state);
|
||||
await saved.remove();
|
||||
|
||||
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
|
||||
let claims = await oidc.fetchUserInfo(tokens.access_token);
|
||||
let identity = oidc.claimsToIdentity(claims);
|
||||
|
||||
let {token} = await Auth.oidcSession(identity);
|
||||
|
||||
let redirect = saved.redirect || '/';
|
||||
return res.redirect(
|
||||
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
|
||||
);
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {getCert} = require('../models/cert');
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
|
||||
router.get('/:host', async function(req, res, next){
|
||||
router.get('/:host', authz.requireDomainRole('viewer', req => req.params.host), async function(req, res, next){
|
||||
try{
|
||||
return res.json(await getCert(req.params.host));
|
||||
}catch(error){
|
||||
|
||||
+21
-13
@@ -2,10 +2,14 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {DnsProvider, Domain} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
const Model = DnsProvider;
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
// Provider listing exposes credentials/config for every domain, so it is
|
||||
// admin-only. The creator of a provider still owns its domains (via created_by)
|
||||
// and manages hosts/records under them without being a global admin.
|
||||
router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model[req.query.detail ? "listDetail" : "list"]()
|
||||
@@ -15,7 +19,7 @@ router.get('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.options('/', async function(req, res, next){
|
||||
router.options('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model.listProviders()
|
||||
@@ -25,9 +29,9 @@ router.options('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username;
|
||||
req.body.created_by = authz.reqUsername(req);
|
||||
let item = await Model.create(req.body);
|
||||
|
||||
return res.json({
|
||||
@@ -41,15 +45,19 @@ router.post('/', async function(req, res, next){
|
||||
|
||||
router.get('/domain', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Domain[req.query.detail ? "listDetail" : "list"]()
|
||||
});
|
||||
let results = await Domain[req.query.detail ? "listDetail" : "list"]();
|
||||
|
||||
// Only surface domains the caller may view.
|
||||
results = await authz.filterViewable(req, results,
|
||||
item => (typeof item === 'string' ? item : item.domain));
|
||||
|
||||
return res.json({results});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/domain/refresh/:item', async function(req, res, next){
|
||||
router.post('/domain/refresh/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
return res.json({results: await item.updateDomains()});
|
||||
@@ -58,7 +66,7 @@ router.post('/domain/refresh/:item', async function(req, res, next){
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/domain/:item', async function(req, res, next){
|
||||
router.get('/domain/:item', authz.requireDomainRole('viewer', authz.resolve.domainParam), async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: [await Domain.get(req.params.item)]
|
||||
@@ -68,7 +76,7 @@ router.get('/domain/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:item', async function(req, res, next){
|
||||
router.get('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
|
||||
return res.json({
|
||||
@@ -80,9 +88,9 @@ router.get('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item', async function(req, res, next){
|
||||
router.put('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.updated_by = req.user.username;
|
||||
req.body.updated_by = authz.reqUsername(req);
|
||||
let item = await Model.get(req.params.item);
|
||||
item = await item.update(req.body);
|
||||
|
||||
@@ -98,7 +106,7 @@ router.put('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:item', async function(req, res, next){
|
||||
router.delete('/:item', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
let count = await item.remove();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const {Grant} = require('../models/grant');
|
||||
const {reqUsername} = require('../middleware/authz');
|
||||
|
||||
// All grant management is admin-only; the gate is applied where this router is
|
||||
// mounted (routes/api.js).
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await Grant.listDetail()});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = reqUsername(req);
|
||||
let grant = await Grant.create(req.body);
|
||||
return res.json({
|
||||
message: `Granted ${req.body.role} to ${req.body.subjectType} "${req.body.subject}"` +
|
||||
(req.body.scope === 'global' ? ' globally.' : ` on ${req.body.domain}.`),
|
||||
...grant,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async function(req, res, next){
|
||||
try{
|
||||
let grant = await Grant.get(req.params.id);
|
||||
await grant.remove();
|
||||
return res.json({message: `Grant ${req.params.id} removed.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+20
-13
@@ -2,22 +2,28 @@
|
||||
|
||||
const router = require('express').Router();
|
||||
const {Host, Domain} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
const Model = Host;
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await Model[req.query.detail ? "listDetail" : "list"](),
|
||||
});
|
||||
let results = await Model[req.query.detail ? "listDetail" : "list"]();
|
||||
|
||||
// Restrict to hosts whose domain the caller may view. list() yields host
|
||||
// strings; listDetail() yields instances with a .host.
|
||||
results = await authz.filterViewable(req, results,
|
||||
item => (typeof item === 'string' ? item : item.host));
|
||||
|
||||
return res.json({results});
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username;
|
||||
req.body.created_by = authz.reqUsername(req);
|
||||
let item = await Model.create(req.body);
|
||||
|
||||
return res.json({
|
||||
@@ -29,7 +35,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/lookup/:item', async function(req, res, next){
|
||||
router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
string: req.params.item,
|
||||
@@ -41,7 +47,8 @@ router.get('/lookup/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/lookupobj', async function(req, res, next){
|
||||
// The full lookup tree exposes every host, so restrict it to admins.
|
||||
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: Model.lookUpObj,
|
||||
@@ -52,7 +59,7 @@ router.get('/lookupobj', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/cache', async function(req, res, next){
|
||||
router.delete('/cache', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let count = await Model.clearCache();
|
||||
|
||||
@@ -65,7 +72,7 @@ router.delete('/cache', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:item', async function(req, res, next){
|
||||
router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
|
||||
return res.json({
|
||||
@@ -77,9 +84,9 @@ router.get('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item', async function(req, res, next){
|
||||
router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
req.body.updated_by = req.user.username;
|
||||
req.body.updated_by = authz.reqUsername(req);
|
||||
let item = await Model.get(req.params.item);
|
||||
item = await item.update(req.body);
|
||||
|
||||
@@ -95,7 +102,7 @@ router.put('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:item', async function(req, res, next){
|
||||
router.delete('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
let count = await item.remove();
|
||||
@@ -110,7 +117,7 @@ router.delete('/:item', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:item/renew', async function(req, res, next){
|
||||
router.put('/:item/renew', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
|
||||
try{
|
||||
let item = await Model.get(req.params.item);
|
||||
item.createWildcardCert();
|
||||
|
||||
@@ -42,6 +42,15 @@ router.get('/users', async function(req, res, next) {
|
||||
res.render('users', {...values});
|
||||
});
|
||||
|
||||
router.get('/grants', async function(req, res, next) {
|
||||
res.render('grants', {...values});
|
||||
});
|
||||
|
||||
// Bare /login (the OIDC callback redirect target) and /login/<path>.
|
||||
router.get('/login', async function(req, res, next) {
|
||||
res.render('login', {...values, redirect: req.query.redirect});
|
||||
});
|
||||
|
||||
router.get('/login/*splat', async function(req, res, next) {
|
||||
res.render('login', {...values, redirect: req.query.redirect});
|
||||
});
|
||||
|
||||
+26
-9
@@ -1,9 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const {User} = require('../models').models;
|
||||
const {User} = require('../models').models;
|
||||
const authz = require('../middleware/authz');
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
// User management is global-admin-only, except the self-service routes below
|
||||
// (GET /me, PUT /password, POST /key) which any authenticated user may call for
|
||||
// their own account.
|
||||
|
||||
router.get('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await User[req.query.detail ? "listDetail" : "list"]()
|
||||
@@ -13,9 +18,9 @@ router.get('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
router.post('/', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
req.body.created_by = req.user.username
|
||||
req.body.created_by = authz.reqUsername(req)
|
||||
|
||||
return res.json(await User.add(req.body));
|
||||
}catch(error){
|
||||
@@ -23,7 +28,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:username', async function(req, res, next){
|
||||
router.delete('/:username', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let user = await User.get(req.params.username);
|
||||
|
||||
@@ -33,14 +38,24 @@ router.delete('/:username', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: the caller's own identity and effective rights. Drives the
|
||||
// frontend's nav/button gating.
|
||||
router.get('/me', async function(req, res, next){
|
||||
try{
|
||||
return res.json({username: req.user.username});
|
||||
let effective = await authz.getEffective(req);
|
||||
return res.json({
|
||||
username: authz.reqUsername(req),
|
||||
groups: req.groups || [],
|
||||
isAdmin: effective.isAdmin,
|
||||
global: effective.global,
|
||||
domains: effective.domains,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: change your own password.
|
||||
router.put('/password', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await req.user.setPassword(req.body)})
|
||||
@@ -49,7 +64,8 @@ router.put('/password', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/password/:username', async function(req, res, next){
|
||||
// Admin: reset another user's password.
|
||||
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let user = await User.get(req.params.username);
|
||||
return res.json({results: await user.setPassword(req.body)});
|
||||
@@ -58,7 +74,7 @@ router.put('/password/:username', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/invite', async function(req, res, next){
|
||||
router.post('/invite', authz.requireAdmin, async function(req, res, next){
|
||||
try{
|
||||
let token = await req.user.invite();
|
||||
|
||||
@@ -68,10 +84,11 @@ router.post('/invite', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// Self-service: add an SSH key to your own account.
|
||||
router.post('/key', async function(req, res, next){
|
||||
try{
|
||||
let added = await User.addSSHkey({
|
||||
username: req.user.username,
|
||||
username: authz.reqUsername(req),
|
||||
key: req.body.key
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user