From b91ef2792dc9bddf56ecee4008a962d4d308ee4b Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 12 Jul 2026 17:12:35 -0400 Subject: [PATCH] Add self-service API tokens (PATs) with UI + Bearer auth (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personal access tokens so scripts/CI can call the management API without a browser session. Each logged-in user mints their own token; it authenticates as the creator (carries their LDAP group permissions, re-resolved live), so the existing permission.byGroup checks apply unchanged. - models/api_token.js: new ApiToken model (sso__ format; id is the lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/ authenticate(); optional expires_at; best-effort last_used_on. No _ttl (persists; lifetime via expires_at). - routes/api_token.js: self-service CRUD (list/get/update/delete/rotate), owner-scoped (created_by === req.user.uid, 403 otherwise). - middleware/auth.js + models/auth.js: accept `Authorization: Bearer sso_...` (precedence over the auth-token session header); checkApiToken collapses every failure to one generic 401 (no existence/secret/expiry leak). - views/api_tokens.ejs + routes/index.js (GET /api-tokens): self-service page (forceLogin, no group gate) — create (token shown once), edit, rotate, revoke. - views/top.ejs: "API Tokens" nav entry visible to all logged-in users. - public/js/app.js: app.apiToken client module. - DEPLOYMENT.md + docs/deployment.md: API tokens section. Co-authored-by: Claude --- DEPLOYMENT.md | 25 ++++ docs/deployment.md | 15 +++ nodejs/app.js | 3 + nodejs/middleware/auth.js | 13 +++ nodejs/models/api_token.js | 78 +++++++++++++ nodejs/models/auth.js | 15 +++ nodejs/models/index.js | 1 + nodejs/public/js/app.js | 34 ++++++ nodejs/routes/api_token.js | 133 ++++++++++++++++++++++ nodejs/routes/index.js | 4 + nodejs/views/api_tokens.ejs | 219 ++++++++++++++++++++++++++++++++++++ nodejs/views/top.ejs | 6 + 12 files changed, 546 insertions(+) create mode 100644 nodejs/models/api_token.js create mode 100644 nodejs/routes/api_token.js create mode 100644 nodejs/views/api_tokens.ejs diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 74ce147..475edf6 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -108,6 +108,31 @@ bare-metal / advanced standalone use; most deployments should use the file. - LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host) - LDAPS (for legacy apps / direct binds): `ldaps://:636` (TLS) +### API tokens (personal access tokens) + +Any logged-in user can mint a long-lived bearer token to call the management +API from scripts/CI/other services, without a browser session. Tokens are +self-service and authenticate **as their creator** — a token carries the +creator's LDAP group permissions, so the same `permission.byGroup` checks apply +(group membership is re-resolved from LDAP live on each request). + +Create one in the UI under **API Tokens** (the token string is shown **once**), +then use it as a bearer token: + +```bash +curl -H "Authorization: Bearer sso__" https://sso.example.com/api/user +``` + +Format: `sso__` — the `id` is the lookup key, the `secret` is +bcrypt-hashed and never stored in plaintext. Rotate or revoke a token from the +same UI page; revocation takes effect immediately. Optional expiry (in days) at +creation. API tokens persist in the bundled Redis, so they survive rebuilds +(Redis is persisted via AOF — see *Backups and restore*). + +The token has the same access as a browser session for that user — an +`app_sso_admin`'s token can manage users/groups; a non-admin's token is limited +to what they could do in the UI. + ### Logs The all-in-one image runs the Node app and slapd (OpenLDAP) in one container, diff --git a/docs/deployment.md b/docs/deployment.md index 047c6fc..c0ea8a6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,6 +54,21 @@ Then `docker compose up -d --build`. - OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` - LDAPS (legacy apps / direct binds): `ldaps://:636` +### API tokens (personal access tokens) + +Any logged-in user can mint a long-lived bearer token to call the management +API from scripts/CI without a browser session. Self-service; authenticates +**as the creator** (carries their LDAP group permissions, re-resolved live). + +Create one under **API Tokens** in the UI (shown once), then: + +```bash +curl -H "Authorization: Bearer sso__" https://sso.example.com/api/user +``` + +Rotate/revoke from the same page (immediate effect). Optional expiry at +creation. Tokens persist in Redis (AOF) and survive rebuilds. + ### Logs The all-in-one image runs the Node app and slapd (OpenLDAP) in one container, diff --git a/nodejs/app.js b/nodejs/app.js index d53bcfa..317b661 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -67,6 +67,9 @@ app.use('/api/token', middleware.auth, require('./routes/token')); app.use('/api/group', middleware.auth, require('./routes/group')); app.use('/api/notification', middleware.auth, require('./routes/notification')); +// Self-service API tokens (PATs) — owner-scoped, no admin group required. +app.use('/api/api-token', middleware.auth, require('./routes/api_token')); + // OAuth 2.0 / OpenID Connect app.use('/oauth', oauthRouter); app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client')); diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index fc544da..fef495c 100755 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -4,6 +4,19 @@ const {Auth} = require('../models/auth'); async function auth(req, res, next){ try{ + // API-only token: `Authorization: Bearer sso__`. + // Takes precedence over the browser session header so a script can call + // the same /api/* routes the UI uses. + const authz = req.header('authorization') || ''; + if(authz.slice(0, 7).toLowerCase() === 'bearer '){ + const user = await Auth.checkApiToken(authz.slice(7)); + if(user && user.uid){ + req.user = user; + return next(); + } + } + + // Browser session: `auth-token: `. let user = await Auth.checkToken({token: req.header('auth-token')}); if(user.uid){ diff --git a/nodejs/models/api_token.js b/nodejs/models/api_token.js new file mode 100644 index 0000000..9558fc7 --- /dev/null +++ b/nodejs/models/api_token.js @@ -0,0 +1,78 @@ +'use strict'; + +const Table = require('.'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +// Self-service personal access token (PAT) for the SSO management API. +// Format: sso__ +// id — 24-char hex, stored plaintext as the record key (O(1) lookup) +// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE +// Authenticated via the `Authorization: Bearer sso_...` header (see +// middleware/auth.js + Auth.checkApiToken). A token authenticates AS its +// creator (created_by) and inherits their LDAP group permissions — the same +// permission.byGroup checks apply, re-resolved live from LDAP each request. +// No `static _ttl`: records persist (lifetime is the optional expires_at field). + +const PREFIX = 'sso_'; +const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex'); + +class ApiToken extends Table { + static _key = 'id'; + static _keyMap = { + 'id': {default: function(){ return randomHex(12) }, type: 'string'}, + 'secret_hash': {isRequired: true, type: 'string', isPrivate: true}, + 'name': {isRequired: true, type: 'string', min: 1, max: 255}, + 'description': {default: '', type: 'string'}, + 'created_by': {isRequired: true, type: 'string'}, + 'created_on': {default: function(){ return (new Date).getTime() }}, + 'updated_on': {default: function(){ return (new Date).getTime() }, always: true}, + 'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never + 'last_used_on': {default: 0, type: 'number'}, + 'is_valid': {default: true, type: 'boolean'}, + } + + get isExpired() { + return this.expires_at > 0 && (new Date).getTime() > this.expires_at; + } + + static async add(data) { + const id = randomHex(12); + const secret = randomHex(24); + data.id = id; + data.secret_hash = await bcrypt.hash(secret, 10); + const token = await this.create(data); + token._raw_token = `${PREFIX}${id}_${secret}`; + return token; + } + + async rotate() { + const secret = randomHex(24); + await this.update({ secret_hash: await bcrypt.hash(secret, 10) }); + return `${PREFIX}${this.id}_${secret}`; + } + + // Validate a raw `sso__` string. Throws a generic Error on any + // failure (wrong format / unknown id / bad secret / revoked / expired) so the + // caller (Auth.checkApiToken) can collapse every case into one 401. + static async authenticate(raw) { + const m = /^sso_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || '')); + if (!m) throw new Error('InvalidApiToken'); + let token; + try { + token = await this.get(m[1]); + } catch (e) { + throw new Error('InvalidApiToken'); + } + if (!token) throw new Error('InvalidApiToken'); + const ok = await bcrypt.compare(m[2], token.secret_hash); + if (!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken'); + // Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never + // fails an otherwise-valid request. + try { await token.update({ last_used_on: (new Date).getTime() }); } catch (_) {} + return token; + } +} +ApiToken.register(); + +module.exports = { ApiToken }; \ No newline at end of file diff --git a/nodejs/models/auth.js b/nodejs/models/auth.js index e564883..85d69f0 100644 --- a/nodejs/models/auth.js +++ b/nodejs/models/auth.js @@ -2,6 +2,7 @@ const {User} = require('./user'); const {Token, AuthToken} = require('./token'); +const {ApiToken} = require('./api_token'); var Auth = {} Auth.errors = {} @@ -40,6 +41,20 @@ Auth.checkToken = async function(data){ } }; +// Validate an `Authorization: Bearer sso__` API token and return the +// owning user (same shape as checkToken). Every failure collapses to the same +// generic login 401 — no leak of whether the token existed vs. wrong secret vs. +// expired. The token authenticates AS its creator; permissions are re-resolved +// from LDAP live (permission.byGroup), so no groups snapshot is stored. +Auth.checkApiToken = async function(raw){ + try{ + let token = await ApiToken.authenticate(raw); + return await User.get(token.created_by); + }catch(error){ + throw this.errors.login(); + } +}; + Auth.logOut = async function(data){ try{ let token = await AuthToken.get(data); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 174f41f..29d9069 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -11,3 +11,4 @@ require('./token'); require('./verification'); require('./oauth_client'); require('./oauth_code'); +require('./api_token'); diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 125833c..89459e3 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -287,6 +287,40 @@ app.oauthClient = (function(app){ return { list, add, remove, update, rotateSecret }; })(app); +app.apiToken = (function(app){ + function list(callback){ + return app.api.get('api-token/', function(error, data){ + if(callback) callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('api-token/', args, function(error, data){ + callback(error, data); + }); + } + + function update(args, callback){ + app.api.put('api-token/' + args.id, args, function(error, data){ + callback(error, data); + }); + } + + function remove(args, callback){ + app.api.delete('api-token/' + args.id, function(error, data){ + callback(error, data); + }); + } + + function rotate(args, callback){ + app.api.post('api-token/' + args.id + '/rotate', {}, function(error, data){ + callback(error, data); + }); + } + + return { list, add, update, remove, rotate }; +})(app); + app.impersonate = (function(app){ function create(uid, callack){ app.api.post('auth/impersonate/' + uid, {}, function(error, data){ diff --git a/nodejs/routes/api_token.js b/nodejs/routes/api_token.js new file mode 100644 index 0000000..8807577 --- /dev/null +++ b/nodejs/routes/api_token.js @@ -0,0 +1,133 @@ +'use strict'; + +// Self-service API token (PAT) management. Every endpoint is owner-scoped: a +// user only sees / mutates tokens where created_by === req.user.uid. No admin +// group is required (unlike routes/oauth_client.js); the Bearer-authed requests +// these tokens enable carry the creator's own LDAP group permissions. + +const router = require('express').Router(); +const { ApiToken } = require('../models/api_token'); + +function forbidden() { + const e = new Error('Forbidden'); + e.name = 'Forbidden'; + e.message = 'You do not own this API token.'; + e.status = 403; + return e; +} + +// Resolve a token the caller owns. Missing or not-yours both raise 403 (no +// existence leak across users; ids are unguessable random hex anyway). +async function getOwned(req, id) { + let token; + try { + token = await ApiToken.get(id); + } catch (e) { + throw forbidden(); + } + if (!token || token.created_by !== req.user.uid) throw forbidden(); + return token; +} + +// Accept `expires_in_days` from the UI and resolve it to an epoch-ms +// `expires_at` (0 = never). Mutates `body` in place. +function resolveExpiry(body) { + if (body.expires_in_days !== undefined && body.expires_in_days !== '') { + const days = Number(body.expires_in_days); + body.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0; + delete body.expires_in_days; + } else if (body.expires_in_days !== undefined) { + body.expires_at = 0; + delete body.expires_in_days; + } + return body; +} + +router.get('/', async function(req, res, next) { + try { + return res.json({ results: await ApiToken.listDetail({ created_by: req.user.uid }) }); + } catch (error) { + next(error); + } +}); + +router.post('/', async function(req, res, next) { + try { + req.body.created_by = req.user.uid; + resolveExpiry(req.body); + + const token = await ApiToken.add(req.body); + + return res.json({ + results: token, + token: token._raw_token, + message: `API token '${token.name}' created. Save it now — it will not be shown again.`, + }); + } catch (error) { + next(error); + } +}); + +router.get('/:id', async function(req, res, next) { + try { + return res.json({ results: await getOwned(req, req.params.id) }); + } catch (error) { + next(error); + } +}); + +router.put('/:id', async function(req, res, next) { + try { + const token = await getOwned(req, req.params.id); + + const update = {}; + for (const k of ['name', 'description']) { + if (req.body[k] !== undefined) update[k] = req.body[k]; + } + // Allow extending/shortening the lifetime. Accept expires_in_days (UI) + // or expires_at (epoch ms); 0 / '' / missing means "no expiry". + if (req.body.expires_in_days !== undefined && req.body.expires_in_days !== '') { + const days = Number(req.body.expires_in_days); + update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0; + } else if (req.body.expires_at !== undefined) { + update.expires_at = Number(req.body.expires_at) || 0; + } + + return res.json({ + results: await token.update(update), + message: `API token '${token.name}' updated.`, + }); + } catch (error) { + next(error); + } +}); + +router.delete('/:id', async function(req, res, next) { + try { + const token = await getOwned(req, req.params.id); + await token.remove(); + + return res.json({ + id: req.params.id, + message: `API token '${token.name}' revoked.`, + }); + } catch (error) { + next(error); + } +}); + +router.post('/:id/rotate', async function(req, res, next) { + try { + const token = await getOwned(req, req.params.id); + const raw = await token.rotate(); + + return res.json({ + token: raw, + message: `API token '${token.name}' rotated. Save it — it will not be shown again.`, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 59b9cf1..24af1a2 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -82,6 +82,10 @@ router.get('/oauth-clients', function(req, res, next) { res.render('oauth_clients', {...values, issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`}); }); +router.get('/api-tokens', function(req, res, next) { + res.render('api_tokens', {...values}); +}); + router.get('/users/:uid', function(req, res, next) { diff --git a/nodejs/views/api_tokens.ejs b/nodejs/views/api_tokens.ejs new file mode 100644 index 0000000..4d2b013 --- /dev/null +++ b/nodejs/views/api_tokens.ejs @@ -0,0 +1,219 @@ +<%- include('top') %> + + + + + + + + + +
+
+
+
New API Token
+ +
+

A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + +
+
+
{{ name }}
+ {{ id_short }} +
+ +
+ {{ #description }}

{{ description }}

{{ /description }} +
+
Token ID
+
{{ id_short }}
+
Created
+
{{{ created_display }}}
+
Last used
+
{{{ last_used_display }}}
+
Expires
+
{{{ expires_display }}}
+
+
+ +
+
+
+<%- include('bottom') %> \ No newline at end of file diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 8f1cb48..f0d47df 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -46,6 +46,12 @@ Profile +