Add self-service API tokens (PATs) with UI + Bearer auth (#35)

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_<id>_<secret> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:12:35 -04:00
committed by GitHub
parent 6920a9f9f0
commit b91ef2792d
12 changed files with 546 additions and 0 deletions
+3
View File
@@ -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'));
+13
View File
@@ -4,6 +4,19 @@ const {Auth} = require('../models/auth');
async function auth(req, res, next){
try{
// API-only token: `Authorization: Bearer sso_<id>_<secret>`.
// 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: <AuthToken uuid>`.
let user = await Auth.checkToken({token: req.header('auth-token')});
if(user.uid){
+78
View File
@@ -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>_<secret>
// 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_<id>_<secret>` 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 };
+15
View File
@@ -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_<id>_<secret>` 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);
+1
View File
@@ -11,3 +11,4 @@ require('./token');
require('./verification');
require('./oauth_client');
require('./oauth_code');
require('./api_token');
+34
View File
@@ -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){
+133
View File
@@ -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;
+4
View File
@@ -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) {
+219
View File
@@ -0,0 +1,219 @@
<%- include('top') %>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-header actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="edit-id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" id="edit-name" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="edit-description" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" id="edit-expires_in_days" class="form-control shadow" min="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer &lt;token&gt;</code></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
// Any logged-in user can manage their own API tokens (self-service).
app.auth.forceLogin();
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copySecret(){ copyField('secretValue'); }
function copyField(id, btn){
var el = document.getElementById(id);
if(!el) return;
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy');
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class');
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); }
}
function fmtTime(ms){
if(!ms) return '—';
return moment(new Date(ms)).fromNow() + ' <span class="text-muted">(' + moment(new Date(ms)).format('YYYY-MM-DD HH:mm') + ')</span>';
}
function fmtExpiry(token){
if(!token.expires_at) return '<span class="badge bg-secondary">never</span>';
if(token.isExpired) return '<span class="badge bg-danger">expired</span>';
return '<span class="badge bg-warning text-dark">' + moment(new Date(token.expires_at)).fromNow() + '</span>';
}
function processToken(token){
tokensById[token.id] = token;
token.id_short = token.id.slice(0, 12) + '…';
token.expires_display = fmtExpiry(token);
token.created_display = fmtTime(token.created_on);
token.last_used_display = fmtTime(token.last_used_on);
return token;
}
async function tableAJAX(){
let data = await app.apiToken.list();
$.scope.apiTokenCard.empty();
$.each(data.results, function(_, token){
$.scope.apiTokenCard.push(processToken(token));
});
}
async function revokeToken(id, name, btn){
var $card = $(btn).closest('.card');
$card.addClass('table-warning');
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
$card.removeClass('table-warning');
if(!confirmed) return;
app.apiToken.remove({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
$.scope.apiTokenCard.remove('id', id);
});
}
async function rotateToken(id, name, btn){
var $card = $(btn).closest('.card');
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
if(!confirmed) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.token);
tableAJAX();
});
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
$('#edit-id').val(id);
$('#edit-name').val(t.name || '');
$('#edit-description').val(t.description || '');
$('#edit-expires_in_days').val('');
editModal.show();
}
function saveEdit(btn){
var $msg = $('#editModal .actionMessage');
var payload = {
id: $('#edit-id').val(),
name: $('#edit-name').val(),
description: $('#edit-description').val(),
expires_in_days: $('#edit-expires_in_days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
editModal.hide();
tableAJAX();
});
}
$(document).ready(function(){
tableAJAX();
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
});
</script>
<div class="row">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
<form action="api-token/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+6
View File
@@ -46,6 +46,12 @@
Profile
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api-tokens">
<i class="fa-solid fa-code"></i>
API Tokens
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users