ZDDC/zddc/internal/handler/tokenhandler.go
2026-06-11 13:32:31 -05:00

418 lines
15 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TokensPathPrefix is the URL of the user-facing self-service token
// management page. Browser GET returns an HTML page; the page fetches
// the JSON API at TokensAPIPathPrefix.
const TokensPathPrefix = "/.tokens"
// TokensAPIPathPrefix is the URL prefix for the token JSON API.
//
// GET /.api/tokens → list current user's tokens (metadata only)
// POST /.api/tokens → create a new token; returns plaintext once
// DELETE /.api/tokens/<id> → revoke a token by short ID or full hash
const TokensAPIPathPrefix = "/.api/tokens"
// MaxTokenDescription caps how much free-form text a user can attach
// to a token. Keeps the on-disk YAML small and the HTML rendering
// simple.
const MaxTokenDescription = 200
// MaxTokensPerUser is a soft cap; refusing to create a new token at
// the limit is enough to prevent runaway accumulation. Operators who
// need more can revoke first.
const MaxTokensPerUser = 50
// tokenAPIView is the JSON shape returned to the management UI. Never
// includes the plaintext token (which is only returned on creation).
type tokenAPIView struct {
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// tokenCreateRequest is the body of POST /.api/tokens.
type tokenCreateRequest struct {
Description string `json:"description,omitempty"`
Expires time.Time `json:"expires,omitempty"`
}
// tokenCreateResponse is the body of a successful POST /.api/tokens.
// The plaintext Token is returned exactly once and is never derivable
// from the persisted file again.
type tokenCreateResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// ServeTokensAPI dispatches requests to the JSON token API. Anonymous
// requests are 401; missing token store yields 503 (token issuance is
// disabled when storage isn't reachable).
func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if store == nil {
http.Error(w, "Token store unavailable", http.StatusServiceUnavailable)
return
}
rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix)
switch {
case rest == "" || rest == "/":
switch r.Method {
case http.MethodGet:
handleTokensList(store, email, w)
case http.MethodPost:
handleTokensCreate(store, email, w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(rest, "/"):
id := strings.TrimPrefix(rest, "/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
handleTokensRevoke(store, email, id, w)
default:
w.Header().Set("Allow", "DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
}
func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) {
list, err := store.List(email)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
views := make([]tokenAPIView, 0, len(list))
for _, t := range list {
views = append(views, tokenAPIView{
ID: t.ID(),
Email: t.Email,
Created: t.Created,
Expires: t.Expires,
Description: t.Description,
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(views)
}
func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req tokenCreateRequest
if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
}
desc := strings.TrimSpace(req.Description)
if len(desc) > MaxTokenDescription {
http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest)
return
}
if !req.Expires.IsZero() && req.Expires.Before(time.Now()) {
http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest)
return
}
// Soft cap to prevent runaway accumulation.
existing, err := store.List(email)
if err == nil && len(existing) >= MaxTokensPerUser {
http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict)
return
}
plaintext, tok, err := store.Generate(email, desc, req.Expires)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(tokenCreateResponse{
Token: plaintext,
ID: tok.ID(),
Email: tok.Email,
Created: tok.Created,
Expires: tok.Expires,
Description: tok.Description,
})
}
func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) {
err := store.Revoke(email, id)
if err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
if errors.Is(err, auth.ErrNotFound) {
http.NotFound(w, nil)
return
}
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
}
// ServeTokensPage renders the self-service token management HTML page.
// The page fetches /.api/tokens via JavaScript; the server-side render
// only emits a static skeleton plus the authenticated user's email so
// the page can show "you are signed in as alice@..." without a flash.
func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
return
}
// Render the token list through the shared tables engine (chrome +
// declarative columns) with a server-injected collection, instead of a
// bespoke chrome-less page. Create + revoke are driven by the generic
// apiActions layer against the existing /.api/tokens endpoints (the
// tables file-save path is untouched). Falls back to the legacy
// skeleton if the store or the tables renderer isn't available.
tablesHTML := EmbeddedTablesHTML()
if store == nil || len(tablesHTML) == 0 {
_, _ = w.Write([]byte(renderTokensPage(email, store != nil)))
return
}
injected, err := injectTableContextObj(tablesHTML, buildTokensTableContext(store, email))
if err != nil {
_, _ = w.Write([]byte(renderTokensPage(email, true)))
return
}
_, _ = w.Write(injected)
}
// buildTokensTableContext assembles the pre-rendered #table-context for the
// token page: the user's tokens as read-only rows + the apiActions config that
// wires create/revoke to /.api/tokens (create surfaces the one-time secret).
func buildTokensTableContext(store *auth.Store, email string) map[string]interface{} {
rows := []map[string]interface{}{}
if list, err := store.List(email); err == nil {
for _, t := range list {
exp := "never"
if !t.Expires.IsZero() {
exp = t.Expires.Format("2006-01-02")
}
rows = append(rows, map[string]interface{}{
"url": t.ID(),
"editable": false,
"data": map[string]interface{}{
"description": t.Description,
"created": t.Created.Format("2006-01-02 15:04"),
"expires": exp,
"id": t.ID(),
},
})
}
}
col := func(field, title, width string) map[string]interface{} {
c := map[string]interface{}{"field": field, "title": title}
if width != "" {
c["width"] = width
}
return c
}
return map[string]interface{}{
"title": "API tokens",
"description": "Bearer tokens for CLI / scripted access as " + email + ". A token's secret is shown once, at creation.",
"addable": false,
"columns": []map[string]interface{}{
col("description", "Description", ""),
col("created", "Created", "12em"),
col("expires", "Expires", "9em"),
col("id", "ID", "16em"),
},
"rows": rows,
"apiActions": map[string]interface{}{
"create": map[string]interface{}{
"url": TokensAPIPathPrefix,
"title": "New token",
"secretField": "token",
"secretLabel": "New token — copy it now, it is shown only once:",
"fields": []map[string]interface{}{
{"name": "description", "label": "Description", "placeholder": "e.g. Field laptop"},
{"name": "expires", "label": "Expires (optional)", "type": "date"},
},
},
"deleteRow": map[string]interface{}{
"urlTemplate": TokensAPIPathPrefix + "/{id}",
"label": "Revoke",
"confirm": "Revoke this token? Any client using it will stop working.",
},
},
}
}
// renderTokensPage builds the HTML for the management page. Kept inline
// (no template files) to match the rest of the handler package; the
// page is small enough that string-concat is readable and there are no
// untrusted-data injection concerns beyond the `email` parameter,
// which is escaped via html.EscapeString.
func renderTokensPage(email string, storeAvailable bool) string {
storeNote := ""
if !storeAvailable {
storeNote = `<p class="warn">Token store is unavailable — the server failed to initialise <code>.zddc.d/tokens/</code>. Operations on this page will return 503.</p>`
}
const css = `
body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
.who { color: #666; margin-bottom: 1.5em; }
fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; }
legend { font-weight: 600; padding: 0 0.4em; }
label { display: block; margin: 0.5em 0; }
label span { display: inline-block; min-width: 9em; }
input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; }
button { padding: 0.4em 0.9em; cursor: pointer; }
.danger { color: #b00; border-color: #b00; }
.warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5em; }
th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; }
.token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; }
.empty { color: #888; font-style: italic; }
`
const js = `
const api = "/.api/tokens";
async function refresh() {
const tbody = document.querySelector("#tokens tbody");
tbody.innerHTML = "";
const r = await fetch(api, { headers: {"Accept": "application/json"} });
if (!r.ok) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">Could not load tokens (' + r.status + ')</td></tr>';
return;
}
const list = await r.json();
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No tokens issued yet.</td></tr>';
return;
}
for (const t of list) {
const tr = document.createElement("tr");
const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)";
const created = new Date(t.created).toLocaleString();
tr.innerHTML = '<td><code>' + escapeHTML(t.id) + '</code></td>'
+ '<td>' + escapeHTML(t.description || "") + '</td>'
+ '<td>' + escapeHTML(created) + '</td>'
+ '<td>' + escapeHTML(expires) + '</td>'
+ '<td><button class="danger" data-id="' + escapeHTML(t.id) + '">Revoke</button></td>';
tbody.appendChild(tr);
}
tbody.querySelectorAll("button.danger").forEach(b => {
b.addEventListener("click", () => revoke(b.dataset.id));
});
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
async function revoke(id) {
if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return;
const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" });
if (!r.ok) {
alert("Revoke failed: " + r.status + " " + r.statusText);
return;
}
refresh();
}
document.getElementById("create").addEventListener("submit", async (ev) => {
ev.preventDefault();
const desc = document.getElementById("desc").value.trim();
const expRaw = document.getElementById("expires").value;
const body = {};
if (desc) body.description = desc;
if (expRaw) body.expires = new Date(expRaw).toISOString();
const r = await fetch(api, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
const out = document.getElementById("created");
if (!r.ok) {
out.innerHTML = '<p class="warn">Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '</p>';
return;
}
const data = await r.json();
out.innerHTML = '<p>New token (copy now — it is shown only once):</p><div class="token-secret">' + escapeHTML(data.token) + '</div>';
document.getElementById("desc").value = "";
document.getElementById("expires").value = "";
refresh();
});
refresh();
`
return `<!doctype html>
<html><head><meta charset="utf-8"><title>ZDDC tokens</title>
<style>` + css + `</style>
</head><body>
<h1>API tokens</h1>
<p class="who">Signed in as <strong>` + html.EscapeString(email) + `</strong></p>
` + storeNote + `
<fieldset>
<legend>Create a new token</legend>
<form id="create">
<label><span>Description</span><input type="text" id="desc" maxlength="200" placeholder="e.g. Field laptop"></label>
<label><span>Expires (optional)</span><input type="datetime-local" id="expires"></label>
<button type="submit">Create token</button>
</form>
<div id="created"></div>
</fieldset>
<fieldset>
<legend>Existing tokens</legend>
<table id="tokens">
<thead><tr><th>ID</th><th>Description</th><th>Created</th><th>Expires</th><th></th></tr></thead>
<tbody></tbody>
</table>
</fieldset>
<script>` + js + `</script>
</body></html>`
}