From a9a48c3445ff7280e7f3c543178a879c4b347e60 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 12 Jul 2026 17:12:38 -0400 Subject: [PATCH] Add self-service API tokens (PATs) with UI + Bearer auth (#119) 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 an OIDC browser session. Each logged-in user mints their own token; it authenticates as the creator (groups snapshotted at mint, mirroring the proxy's browser AuthToken), and the existing authz layer (Permission.effectiveFor / roles.resolveEffective) applies unchanged. Local groups and owned-domain rights are recomputed live; only SSO/LDAP group membership is the mint-time snapshot. - models/api_token.js: new ApiToken model (prx__ format; id is the lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/ authenticate(); optional expires_at; best-effort last_used_on; groups snapshot. No _ttl (persists). Deliberately NOT wrapped in ModelPs (so the last_used_on write on the auth path doesn't spam the socket). - routes/api_token.js: self-service CRUD (list/get/update/delete/rotate), owner-scoped (created_by === reqUsername(req), 403 otherwise). - middleware/auth.js + models/auth.js: accept `Authorization: Bearer prx_...` (precedence over the auth-token session header). Builds a synthetic req.token that satisfies the only three req.token reads (auth.js .user/.groupsArray, authz.js reqUsername .created_by) so the authz layer works unchanged. checkApiToken collapses every failure to one generic 401 (no leak). - views/api_tokens.ejs + routes/render.js (GET /api-tokens): self-service page (forceLogin, no admin gate) — create (token shown once), 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/docker.md: API tokens section. Co-authored-by: Claude --- DEPLOYMENT.md | 28 +++++++ docs/docker.md | 18 +++++ nodejs/middleware/auth.js | 21 +++++ nodejs/models/api_token.js | 89 +++++++++++++++++++++ nodejs/models/auth.js | 18 +++++ nodejs/models/index.js | 1 + nodejs/public/js/app.js | 40 ++++++++++ nodejs/routes/api.js | 3 + nodejs/routes/api_token.js | 121 ++++++++++++++++++++++++++++ nodejs/routes/render.js | 4 + nodejs/views/api_tokens.ejs | 154 ++++++++++++++++++++++++++++++++++++ nodejs/views/top.ejs | 5 ++ 12 files changed, 502 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 1ed85bc..2fd3033 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -87,6 +87,34 @@ OpenResty-runtime / process env, not `app_*` config, so they stay in the compose proxies the UI under its own TLS) - Health: `http://127.0.0.1:3000/health` → `{"status":"ok"}` +### 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 an OIDC browser session. Tokens are +self-service and authenticate **as their creator**: the creator's groups are +snapshotted at mint time (mirroring how the proxy's browser session captures +groups at login — the proxy never re-queries the IdP), and the existing authz +layer (`Permission.effectiveFor` / `roles.resolveEffective`) applies unchanged. +Local groups and owned-domain rights are recomputed live each request; only the +SSO/LDAP group membership is the mint-time snapshot. + +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 prx__" https://proxy.example.com/api/host +``` + +Format: `prx__` — the `id` is the lookup key, the `secret` is +bcrypt-hashed and never stored in plaintext. Rotate or revoke from the same page +(immediate effect). Optional expiry (in days) at creation. Tokens persist in the +bundled Redis (AOF — see *Backups and restore*), so they survive rebuilds. + +The token carries the creator's effective rights: a global admin's token can +manage Hosts/Users/Groups; a domain manager's token can manage their own +domains but `requireAdmin` routes return 403. To tighten permissions after group +changes, revoke and re-mint the token. + ### OpenResty runtime env | Variable | Default | Description | diff --git a/docs/docker.md b/docs/docker.md index 7ecfa6f..3dd48a3 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -114,6 +114,24 @@ The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo automates all four steps with `./setup.sh` — see [theta-env docs](https://theta42.github.io/theta-env/). +## 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 an OIDC browser session. Self-service; authenticates as +the creator (groups snapshotted at mint; authz layer unchanged). + +Create one under **API Tokens** in the UI (shown once), then: + +```bash +curl -H "Authorization: Bearer prx__" https://proxy.example.com/api/host +``` + +Rotate/revoke from the same page (immediate effect). Optional expiry at +creation. The token carries the creator's rights (admin → full mgmt API; +domain manager → those domains; `requireAdmin` routes 403). To tighten after +group changes, revoke + re-mint. Tokens persist in Redis (AOF) and survive +rebuilds. + ## Bare metal Prefer a systemd install? See the [Installation Guide](installation.html) for diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index 3de1a8b..a12b20a 100755 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -4,6 +4,27 @@ const {Auth} = require('../models/auth'); async function auth(req, res, next){ try{ + // API-only token: `Authorization: Bearer prx__`. Takes + // precedence over the browser session header so scripts hit the same + // /api/* routes the UI uses. The synthetic req.token below satisfies the + // only req.token reads in the codebase: .user, .groupsArray(), .created_by + // (see middleware/authz.js reqUsername). + const authz = req.header('authorization') || ''; + if(authz.slice(0, 7).toLowerCase() === 'bearer '){ + const t = await Auth.checkApiToken(authz.slice(7)); + req.token = { + user: {username: t.created_by}, + created_by: t.created_by, + groupsArray: () => Array.isArray(t.groups) ? t.groups : [], + check: () => true, + is_valid: true, + }; + req.user = req.token.user; + req.groups = req.token.groupsArray(); + return next(); + } + + // Browser session: `auth-token: `. req.token = await Auth.checkToken(req.header('auth-token')); req.user = req.token.user; // Session group memberships captured at login, used by authz middleware. diff --git a/nodejs/models/api_token.js b/nodejs/models/api_token.js new file mode 100644 index 0000000..52d71b8 --- /dev/null +++ b/nodejs/models/api_token.js @@ -0,0 +1,89 @@ +'use strict'; + +const Table = require('.'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +// Self-service personal access token (PAT) for the proxy management API. +// Format: prx__ +// 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 `Authorization: Bearer prx_...` (see middleware/auth.js). +// A token authenticates AS its creator: created_by + the groups the creator +// held at mint time are snapshotted onto the record (mirroring how the proxy's +// browser AuthToken captures groups at login — the proxy never re-queries the +// IdP). The authz layer reuses req.user/req.groups unchanged; local groups and +// owned-domain rights are recomputed live by Permission.effectiveFor. +// +// No `static _ttl`: records persist (lifetime is the optional expires_at field). +// Deliberately NOT wrapped in ModelPs — the best-effort last_used_on write on +// the auth path would otherwise spam the socket on every API call. + +const PREFIX = 'prx_'; +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', min: 3, max: 500}, + // Group memberships captured at mint (from the creator's session) — the + // mint-time snapshot the token authenticates with. + 'groups': {default: function(){ return [] }, isRequired: false, type: 'object'}, + '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); + if(!Array.isArray(data.groups)) data.groups = []; + 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 `prx__` string. Throws a generic Error on any + // failure so the caller (Auth.checkApiToken) can collapse every case into + // one 401 (no existence / wrong-secret / expired leak). + static async authenticate(raw){ + const m = /^prx_([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 34f33b5..bf1efdc 100644 --- a/nodejs/models/auth.js +++ b/nodejs/models/auth.js @@ -2,6 +2,7 @@ const Table = require('../models'); const {User, AuthToken} = Table.models; +const {ApiToken} = require('./api_token'); /** * Auth Model @@ -101,6 +102,23 @@ class Auth{ } } + /** + * Validate an `Authorization: Bearer prx__` API token. + * + * Returns the authenticated ApiToken record (with created_by + the + * mint-time groups snapshot); middleware/auth.js wraps it into the + * req.token shape the authz layer expects. Every failure collapses to the + * same generic login 401 — no leak of existence / wrong secret / expired. + */ + static async checkApiToken(raw){ + try{ + return await ApiToken.authenticate(raw); + }catch(error){ + console.log('api-token check error', error); + throw this.errors.login(); + } + } + /** * Destroy an authentication token (logout). * diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 6477866..71a5f68 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -15,3 +15,4 @@ require('./local_group'); require('./permission'); require('./oidc_state'); require('./sso_session'); +require('./api_token'); diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index db2d79f..8dc8768 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -51,3 +51,43 @@ app.host = (function(app){ clearCache: clearCache, } })(app); + +app.apiToken = (function(app){ + function list(callback){ + app.api.get('api-token/', function(error, data){ + callback(error, data); + }); + } + + function get(id, callback){ + app.api.get('api-token/' + id, function(error, data){ + 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, get, add, update, remove, rotate}; +})(app); diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 23e0e3c..9e3ea4d 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -28,4 +28,7 @@ router.use('/permission', middleware.auth, authz.requireAdmin, require('./permis // Local group management is global-admin-only. router.use('/group', middleware.auth, authz.requireAdmin, require('./group')); +// Self-service API tokens (PATs) — owner-scoped, no admin gate required. +router.use('/api-token', middleware.auth, require('./api_token')); + module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/api_token.js b/nodejs/routes/api_token.js new file mode 100644 index 0000000..0c1b8ae --- /dev/null +++ b/nodejs/routes/api_token.js @@ -0,0 +1,121 @@ +'use strict'; + +// Self-service API token (PAT) management. Every endpoint is owner-scoped: a +// user only sees / mutates tokens where created_by === reqUsername(req). No +// authz.requireAdmin gate (self-service); the Bearer-authed requests these +// tokens enable carry the creator's own effective rights via the authz layer. + +const router = require('express').Router(); +const {ApiToken} = require('../models/api_token'); +const {reqUsername} = require('../middleware/authz'); + +function forbidden(){ + let error = new Error('Forbidden'); + error.name = 'Forbidden'; + error.message = 'You do not own this API token.'; + error.status = 403; + return error; +} + +// Resolve a token the caller owns. Missing or not-yours both raise 403 (no +// existence leak; ids are unguessable random hex anyway). +async function getOwned(req, id){ + let token; + try{ + token = await ApiToken.get(id); + }catch(e){ + throw forbidden(); + } + const me = reqUsername(req); + if(!token || token.created_by !== me) throw forbidden(); + return token; +} + +router.get('/', async function(req, res, next){ + try{ + return res.json({results: await ApiToken.listDetail({created_by: reqUsername(req)})}); + }catch(error){ + next(error); + } +}); + +router.post('/', async function(req, res, next){ + try{ + const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined + ? Number(req.body.expires_in_days) : 0; + + const token = await ApiToken.add({ + name: req.body.name, + description: req.body.description || '', + created_by: reqUsername(req), + // Snapshot the creator's current groups (mint-time, like AuthToken). + groups: req.groups || [], + expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0, + }); + + 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]; + } + 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/render.js b/nodejs/routes/render.js index 5b975fc..cf4c216 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -60,6 +60,10 @@ router.get('/profile', async function(req, res, next) { res.render('profile', {...values}); }); +router.get('/api-tokens', async function(req, res, next) { + res.render('api_tokens', {...values}); +}); + // Bare /login (the OIDC callback redirect target) and /login/. router.get('/login', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); diff --git a/nodejs/views/api_tokens.ejs b/nodejs/views/api_tokens.ejs new file mode 100644 index 0000000..5c57cb5 --- /dev/null +++ b/nodejs/views/api_tokens.ejs @@ -0,0 +1,154 @@ +<%- include('top') %> + + + + + + +
+
+
+
New API Token
+ +
+

A personal access token lets scripts and services call the proxy 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 33a974f..f567aeb 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -71,6 +71,11 @@ Profile +