Add self-service API tokens (PATs) with UI + Bearer auth (#119)
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_<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; 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 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,34 @@ OpenResty-runtime / process env, not `app_*` config, so they stay in the compose
|
|||||||
proxies the UI under its own TLS)
|
proxies the UI under its own TLS)
|
||||||
- Health: `http://127.0.0.1:3000/health` → `{"status":"ok"}`
|
- 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_<id>_<secret>" https://proxy.example.com/api/host
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: `prx_<id>_<secret>` — 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
|
### OpenResty runtime env
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
@@ -114,6 +114,24 @@ The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
|
|||||||
automates all four steps with `./setup.sh` — see
|
automates all four steps with `./setup.sh` — see
|
||||||
[theta-env docs](https://theta42.github.io/theta-env/).
|
[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_<id>_<secret>" 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
|
## Bare metal
|
||||||
|
|
||||||
Prefer a systemd install? See the [Installation Guide](installation.html) for
|
Prefer a systemd install? See the [Installation Guide](installation.html) for
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ const {Auth} = require('../models/auth');
|
|||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
// API-only token: `Authorization: Bearer prx_<id>_<secret>`. 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: <AuthToken uuid>`.
|
||||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||||
req.user = req.token.user;
|
req.user = req.token.user;
|
||||||
// Session group memberships captured at login, used by authz middleware.
|
// Session group memberships captured at login, used by authz middleware.
|
||||||
|
|||||||
@@ -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>_<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 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_<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 = /^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};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const Table = require('../models');
|
const Table = require('../models');
|
||||||
const {User, AuthToken} = Table.models;
|
const {User, AuthToken} = Table.models;
|
||||||
|
const {ApiToken} = require('./api_token');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Model
|
* Auth Model
|
||||||
@@ -101,6 +102,23 @@ class Auth{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an `Authorization: Bearer prx_<id>_<secret>` 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).
|
* Destroy an authentication token (logout).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ require('./local_group');
|
|||||||
require('./permission');
|
require('./permission');
|
||||||
require('./oidc_state');
|
require('./oidc_state');
|
||||||
require('./sso_session');
|
require('./sso_session');
|
||||||
|
require('./api_token');
|
||||||
|
|||||||
@@ -51,3 +51,43 @@ app.host = (function(app){
|
|||||||
clearCache: clearCache,
|
clearCache: clearCache,
|
||||||
}
|
}
|
||||||
})(app);
|
})(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);
|
||||||
|
|||||||
@@ -28,4 +28,7 @@ router.use('/permission', middleware.auth, authz.requireAdmin, require('./permis
|
|||||||
// Local group management is global-admin-only.
|
// Local group management is global-admin-only.
|
||||||
router.use('/group', middleware.auth, authz.requireAdmin, require('./group'));
|
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;
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -60,6 +60,10 @@ router.get('/profile', async function(req, res, next) {
|
|||||||
res.render('profile', {...values});
|
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/<path>.
|
// Bare /login (the OIDC callback redirect target) and /login/<path>.
|
||||||
router.get('/login', async function(req, res, next) {
|
router.get('/login', async function(req, res, next) {
|
||||||
res.render('login', {...values, redirect: req.query.redirect});
|
res.render('login', {...values, redirect: req.query.redirect});
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<!-- 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="copyToken()" 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 <token></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 tokensById = {};
|
||||||
|
|
||||||
|
function showSecret(secret){
|
||||||
|
document.getElementById('secretValue').value = secret;
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
function copyToken(){
|
||||||
|
var el = document.getElementById('secretValue');
|
||||||
|
el.select(); el.setSelectionRange(0, 99999);
|
||||||
|
try { document.execCommand('copy'); } catch(_){}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 text-bg-secondary">never</span>';
|
||||||
|
if(token.isExpired) return '<span class="badge text-bg-danger">expired</span>';
|
||||||
|
return '<span class="badge text-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableAJAX(){
|
||||||
|
app.apiToken.list(function(error, data){
|
||||||
|
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger');
|
||||||
|
$.scope.apiTokenCard.empty();
|
||||||
|
(data.results || []).forEach(function(token){
|
||||||
|
$.scope.apiTokenCard.push(processToken(token));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeToken(id, name, btn){
|
||||||
|
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
|
||||||
|
app.apiToken.remove({id: id}, function(error, data){
|
||||||
|
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
|
||||||
|
$.scope.apiTokenCard.remove('id', id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateToken(id, name, btn){
|
||||||
|
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
|
||||||
|
app.apiToken.rotate({id: id}, function(error, data){
|
||||||
|
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
|
||||||
|
showSecret(data.token);
|
||||||
|
tableAJAX();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
tableAJAX();
|
||||||
|
// After a successful create, show the raw token once + refresh the list.
|
||||||
|
$('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 proxy 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" name="name" placeholder="CI host sync" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" 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" 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="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') %>
|
||||||
@@ -71,6 +71,11 @@
|
|||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://github.com/theta42/proxy" target="_blank">
|
<a class="nav-link" href="https://github.com/theta42/proxy" target="_blank">
|
||||||
<i class="fa-brands fa-github"></i>
|
<i class="fa-brands fa-github"></i>
|
||||||
|
|||||||
Reference in New Issue
Block a user