Merge pull request #11 from theta42/feature/api-tokens
Add self-service API tokens (PATs)
This commit is contained in:
@@ -9,6 +9,24 @@ const { Auth } = require('../models');
|
||||
|
||||
async function auth(req, res, next){
|
||||
try{
|
||||
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. 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() : [];
|
||||
|
||||
@@ -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>_<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 `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_<id>_<secret>` 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};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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;
|
||||
+107
-1
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
@@ -50,6 +50,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<p class="text-muted small px-3 pt-3 mb-0">
|
||||
Personal access tokens authenticate as you against this jump host's own API
|
||||
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
|
||||
no group claims, so it can't reach admin-only endpoints.
|
||||
</p>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody id="api-tokens"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
@@ -68,6 +89,90 @@
|
||||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td></tr>');
|
||||
});
|
||||
}
|
||||
function tokenRows(tokens){
|
||||
var $b = $('#api-tokens').empty();
|
||||
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); return; }
|
||||
tokens.forEach(function(t){
|
||||
var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
|
||||
var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
|
||||
$b.append(
|
||||
'<tr>'
|
||||
+ '<td>' + app.jump.esc(t.name) + '</td>'
|
||||
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
|
||||
+ '<td class="text-muted small">' + lastUsed + '</td>'
|
||||
+ '<td class="text-muted small">' + expires + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td>'
|
||||
+ '</tr>'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function loadApiTokens(){
|
||||
app.apiToken.list(function(error, data){
|
||||
if(error) return tokenRows([]);
|
||||
tokenRows(data && data.results);
|
||||
});
|
||||
}
|
||||
|
||||
function showToken(title, token){
|
||||
app.modal.open({title: title, bodyHtml:
|
||||
'<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" class="form-control font-monospace" readonly value="' + app.jump.esc(token) + '"></div>'
|
||||
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
|
||||
});
|
||||
}
|
||||
|
||||
function createApiToken(){
|
||||
var $body = app.modal.open({title: 'New API Token', bodyHtml:
|
||||
'<div class="mb-3">'
|
||||
+ '<label class="form-label">Name</label>'
|
||||
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Expires in (days, blank = never)</label>'
|
||||
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
|
||||
+ '</div>'
|
||||
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
|
||||
});
|
||||
$body.find('#new-token-name').focus();
|
||||
}
|
||||
|
||||
function submitApiToken(){
|
||||
var name = $('#new-token-name').val().trim();
|
||||
var $card = $('#api-tokens').closest('.card');
|
||||
if(!name) return app.messages.action('Name is required', $card, 'danger');
|
||||
app.apiToken.add({
|
||||
name: name,
|
||||
expires_in_days: $('#new-token-days').val(),
|
||||
}, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
|
||||
showToken('API Token Created', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
async function revokeApiToken(id, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
|
||||
if(!ok) return;
|
||||
app.apiToken.remove(id, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
async function rotateApiToken(id, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
|
||||
if(!ok) return;
|
||||
app.apiToken.rotate(id, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
|
||||
showToken('API Token Rotated', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(async function(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
@@ -84,6 +189,7 @@
|
||||
if(error) return hostRows('#my-hosts', []);
|
||||
hostRows('#my-hosts', data && data.results);
|
||||
});
|
||||
loadApiTokens();
|
||||
});
|
||||
</script>
|
||||
<%- include('bottom') %>
|
||||
|
||||
Reference in New Issue
Block a user