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 }