package web
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// tokensData is the agent-token page: the inventory, and — on the one response
// that follows a mint — the plaintext that will never be shown again.
//
// Minted is empty on every other render. It is a field on the page rather than
// a flash cookie or a redirect parameter on purpose: a secret in a URL lands in
// the browser history and in any proxy log between here and the operator, and a
// secret in a cookie is a secret stored twice.
type tokensData struct {
Tokens []tokenRow
Minted string
MintedName string
}
// tokenRow is one credential as a listing line. There is no hash column:
// service.AgentToken carries no hash, which is the layer saying that nothing
// above it has any business with the stored value.
type tokenRow struct {
ID int
Name string
Created string
Revoked string // empty while the token is still active
Active bool
}
// handleTokens renders the token inventory.
//
// Owner-only, and the refusal is a 403 rather than the read plane's login
// redirect: an agent reaching this page is authenticated already, so redirecting
// it to log in would answer a question it did not ask. An anonymous browser is
// sent to meta the usual way, because for a human the answer really is "log in".
func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
p := authn.PrincipalFromContext(r.Context())
if p.IsAnonymous() {
s.loginRedirect(w, r)
return
}
if !p.IsOwner() {
s.renderError(w, r, http.StatusForbidden, "only the instance owner may manage agent tokens")
return
}
s.renderTokens(w, r, p, tokensData{})
}
// handleTokenIssue mints a token and renders the page with the plaintext shown
// once.
//
// This is the one write in this package that does not end in a
// post-redirect-get. A redirect would either drop the secret — the whole point
// of the request — or carry it in a URL. So the POST renders, and the form's
// name field is what a reload would re-submit: minting a second token by
// accident is recoverable in one click on this very page, whereas a lost token
// is not recoverable at all.
func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
p, ok := s.tokenWriter(w, r)
if !ok {
return
}
token, row, err := s.reader.IssueToken(r.Context(), p, r.FormValue("name"))
if err != nil {
s.fail(w, r, err)
return
}
s.renderTokens(w, r, p, tokensData{Minted: token, MintedName: row.Name})
}
// handleTokenRevoke stamps a token revoked and redirects back to the listing.
func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
p, ok := s.tokenWriter(w, r)
if !ok {
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil || id <= 0 {
s.renderError(w, r, http.StatusNotFound, "no such agent token")
return
}
if err := s.reader.RevokeToken(r.Context(), p, id); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/tokens", http.StatusSeeOther)
}
// tokenWriter is the shared gate on both token writes: owner-only, and the same
// cross-site guard approve/reject use — the CSRF defense a form post needs when
// the session cookie is meta's and this service cannot set its SameSite. It
// answers the request itself when it refuses, so a caller only checks ok.
func (s *Server) tokenWriter(w http.ResponseWriter, r *http.Request) (authn.Principal, bool) {
p := authn.PrincipalFromContext(r.Context())
if !p.IsOwner() {
s.renderError(w, r, http.StatusForbidden, "only the instance owner may manage agent tokens")
return authn.Principal{}, false
}
if !s.sameOrigin(r) {
s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
return authn.Principal{}, false
}
return p, true
}
// renderTokens reads the inventory and renders the page, carrying through
// whatever the caller already has to show (a freshly minted token, or nothing).
func (s *Server) renderTokens(w http.ResponseWriter, r *http.Request, p authn.Principal, data tokensData) {
tokens, err := s.reader.ListTokens(r.Context(), p)
if err != nil {
s.fail(w, r, err)
return
}
data.Tokens = tokenRows(tokens)
vd := s.chrome(r)
vd.Title = "Agent tokens"
vd.Data = data
s.render(w, http.StatusOK, "tokens", vd)
}
// tokenRows turns the service view onto listing lines, formatting the two
// timestamps here so the template holds no date logic.
func tokenRows(ts []service.AgentToken) []tokenRow {
rows := make([]tokenRow, 0, len(ts))
for _, t := range ts {
row := tokenRow{
ID: t.ID,
Name: t.Name,
Created: t.Created.UTC().Format(time.RFC3339),
Active: t.Active(),
}
if !row.Active {
row.Revoked = t.Revoked.UTC().Format(time.RFC3339)
}
rows = append(rows, row)
}
return rows
}