Add token rotation
jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b44b92975
commit
0b2159b1a4
21 files changed
+755
-46
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js";
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js";
|
||||
import { api, get, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -101,20 +101,69 @@ export async function tokensDialog(u, grants, own) {
|
||||
|
||||
export function tokenTable(tokens, redraw) {
|
||||
return table([
|
||||
{ label: "Name", render: (t) => h("strong", null, t.name) },
|
||||
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
|
||||
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
|
||||
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
|
||||
{ label: "", render: (t) => h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Token revoked");
|
||||
redraw();
|
||||
} }, "Revoke"), tdClass: "actions-cell" },
|
||||
{ label: "", render: (t) => [
|
||||
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
|
||||
h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Token revoked");
|
||||
redraw();
|
||||
} }, "Revoke")], tdClass: "actions-cell nowrap" },
|
||||
], tokens, { empty: "No tokens." });
|
||||
}
|
||||
|
||||
// rotationNote says when a token last got a new secret, and whether its
|
||||
// old secret still works.
|
||||
function rotationNote(t) {
|
||||
const parts = [];
|
||||
if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) {
|
||||
parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until)));
|
||||
} else if (t.rotated_at) {
|
||||
parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at)));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// rotateDialog gives a token a new secret. The token keeps its name,
|
||||
// grants and ID; only the programs using it need the new secret.
|
||||
async function rotateDialog(t, redraw) {
|
||||
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
|
||||
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
|
||||
if (t.expires_at) expOpts.push(["never", "never expires"]);
|
||||
const exp = select(expOpts, "");
|
||||
const err = h("div");
|
||||
const box = h("div");
|
||||
const form = h("div", null,
|
||||
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
|
||||
h("div", { class: "form-grid" },
|
||||
field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."),
|
||||
field("Expires", exp)),
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => {
|
||||
mount(err);
|
||||
const body = {};
|
||||
if (grace.value) body.grace = grace.value;
|
||||
if (exp.value === "never") body.no_expiry = true;
|
||||
else if (exp.value) body.expires_in = exp.value;
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
|
||||
mount(box, h("div", { class: "msg ok" },
|
||||
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
|
||||
copyBox(r.token),
|
||||
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works.")));
|
||||
if (redraw) redraw();
|
||||
} catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); }
|
||||
} }, "Rotate")));
|
||||
mount(box, form);
|
||||
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
|
||||
}
|
||||
|
||||
export function newTokenForm(base, grants, redraw) {
|
||||
const err = h("div");
|
||||
const out = h("div");
|
||||
|
||||
@@ -78,6 +78,7 @@ h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
.right { text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
|
||||
@@ -49,12 +49,15 @@ export function fmtTime(iso) {
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return "";
|
||||
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const s = Math.abs(diff);
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return Math.floor(s / 60) + " min ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + " h ago";
|
||||
if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago";
|
||||
return fmtTime(iso);
|
||||
let n;
|
||||
if (s < 3600) n = Math.floor(s / 60) + " min";
|
||||
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
||||
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
||||
else return fmtTime(iso);
|
||||
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
||||
}
|
||||
|
||||
export function time(iso) {
|
||||
|
||||
Reference in new issue
Block a user