From 4326d5588e88290f269e4bb392f664cbc5e7026a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 27 Jul 2026 19:44:04 -0400 Subject: [PATCH] =?UTF-8?q?Add=20self-service=20API=20tokens=20(PATs)=20?= =?UTF-8?q?=E2=80=94=20the=20UI=20had=20no=20way=20to=20create=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jump-host had zero API-token support: no model, no route, no UI, and Auth.checkApiToken was explicitly absent from the createOidcClient() call (per the comment it left behind). proxy and sso-manager-node both have this; jump-host didn't. Ports proxy's models/api_token.js + routes/api_token.js pattern (jmp_ prefix instead of prx_), wires checkApiToken into createOidcClient(), adds Bearer-token support to middleware/auth.js, and adds a token management card to dashboard.ejs (create/list/rotate/revoke) using app.modal/ app.messages. Scope note: a jump-host token carries no group claims (unlike proxy's, which snapshots the creator's groups), so it authenticates as its creator for non-admin routes (e.g. GET /api/user/hosts) but can never pass requireAdmin — a deliberate, conservative default rather than recomputing live admin status per-request. Co-Authored-By: Claude Sonnet 5 --- nodejs/middleware/auth.js | 18 ++++++ nodejs/models/api_token.js | 83 +++++++++++++++++++++++++ nodejs/models/index.js | 11 ++-- nodejs/public/js/app.js | 9 +++ nodejs/routes/api.js | 4 ++ nodejs/routes/api_token.js | 120 +++++++++++++++++++++++++++++++++++++ nodejs/views/dashboard.ejs | 108 ++++++++++++++++++++++++++++++++- 7 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 nodejs/models/api_token.js create mode 100644 nodejs/routes/api_token.js diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index e141989..a029b15 100644 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -9,6 +9,24 @@ const { Auth } = require('../models'); async function auth(req, res, next){ try{ + // API-only token: `Authorization: Bearer jmp__`. Carries no + // group claims (see models/api_token.js), so it authenticates as its + // creator but never passes requireAdmin below. + 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: () => [], + check: () => true, + is_valid: true, + }; + req.user = req.token.user; + req.groups = []; + return next(); + } + req.token = await Auth.checkToken(req.header('auth-token')); req.user = req.token.user; req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : []; diff --git a/nodejs/models/api_token.js b/nodejs/models/api_token.js new file mode 100644 index 0000000..de09387 --- /dev/null +++ b/nodejs/models/api_token.js @@ -0,0 +1,83 @@ +'use strict'; + +const Table = require('.'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +// Self-service personal access token (PAT) for the jump host's own API. +// Format: jmp__ +// 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 jmp_...`. Mirrors proxy's +// models/api_token.js — see that file for the fuller design notes. jump-host +// has no per-user group snapshot the way proxy/sso do (its authz is a single +// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token +// authenticates as its creator only; the auth middleware re-derives +// admin-ness from that user's current groups, same as a live session. +// +// No `static _ttl`: records persist (lifetime is the optional expires_at field). + +const PREFIX = 'jmp_'; +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}, + '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 `jmp__` 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 = /^jmp_([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}; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 3706733..03d438c 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -32,14 +32,17 @@ async function getRedis() { module.exports.getRedis = getRedis; -// Register models (order matters: User before AuthToken's relation resolves). +// Register models (order matters: User before AuthToken's relation resolves, +// and before ApiToken so `require('.')`'s Table is already exporting User). require('./user_redis'); // User (redis-backed local + OIDC JIT) +const { ApiToken } = require('./api_token'); +module.exports.ApiToken = ApiToken; // Shared OIDC client (authorization-code + PKCE): session models (Token, // AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start -// /oidc/callback router — all created on this app's Table/redis. jump-host has -// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent). -const oidcClient = createOidcClient({ Table }); +// /oidc/callback router — all created on this app's Table/redis. checkApiToken +// wraps ApiToken.authenticate, same wiring as proxy's models/index.js. +const oidcClient = createOidcClient({ Table, checkApiToken: (raw) => ApiToken.authenticate(raw) }); module.exports.Token = oidcClient.Token; module.exports.AuthToken = oidcClient.AuthToken; module.exports.OidcState = oidcClient.OidcState; diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index c1b7fdf..5bfcecf 100644 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -15,6 +15,15 @@ app.jump = (function(app){ return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts}; })(app); +// Self-service API token (PAT) management. +app.apiToken = (function(app){ + function list(cb){ app.api.get('api-token/', cb); } + function add(args, cb){ app.api.post('api-token/', args, cb); } + function remove(id, cb){ app.api.delete('api-token/' + id, cb); } + function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); } + return {list: list, add: add, remove: remove, rotate: rotate}; +})(app); + // Shared render helpers. app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; }; app.jump.esc = function(s){ return $('
').text(s == null ? '' : String(s)).html(); }; diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index c97d660..1293db1 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -9,6 +9,10 @@ router.use('/auth', require('../models').authRouter); // Who am I — needs a valid session but no admin gate (drives the login state). router.use('/user', middleware.auth, require('./user')); +// Self-service API token (PAT) management — any authenticated user, no +// admin gate (see routes/api_token.js for why a token can't reach admin routes). +router.use('/api-token', middleware.auth, require('./api_token')); + // Jump-host data — admin only (audit log, active sessions, metrics). router.use('/', middleware.auth, middleware.requireAdmin, require('./jump')); diff --git a/nodejs/routes/api_token.js b/nodejs/routes/api_token.js new file mode 100644 index 0000000..b1d8e88 --- /dev/null +++ b/nodejs/routes/api_token.js @@ -0,0 +1,120 @@ +'use strict'; + +// Self-service API token (PAT) management. Every endpoint is owner-scoped: a +// user only sees / mutates tokens where created_by === req.user.username. +// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only +// (no requireAdmin) — any authenticated user may mint one, but the token +// itself carries no group claims (see models/api_token.js), so it can only +// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated +// ones under routes/jump.js. + +const router = require('express').Router(); +const {ApiToken} = require('../models'); + +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(); + } + if(!token || token.created_by !== req.user.username) throw forbidden(); + return token; +} + +router.get('/', async function(req, res, next){ + try{ + return res.json({results: await ApiToken.listDetail({created_by: req.user.username})}); + }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: req.user.username, + 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; diff --git a/nodejs/views/dashboard.ejs b/nodejs/views/dashboard.ejs index efab4bb..de64525 100644 --- a/nodejs/views/dashboard.ejs +++ b/nodejs/views/dashboard.ejs @@ -37,7 +37,7 @@
-
+
Top hosts
@@ -50,6 +50,27 @@
+
+
+
+
+ API Tokens + +
+ +

+ Personal access tokens authenticate as you against this jump host's own API + (e.g. GET /api/user/hosts) — not for SSH login. A token carries + no group claims, so it can't reach admin-only endpoints. +

+ + + +
NameCreatedLast usedExpires
+
+
+
+ <%- include('bottom') %>