~bigbes/sr-ht-spec

ref: 0bbb37594f116e34df1c52c412155974ac17e7ef sr-ht-spec/web/tokens.go -rw-r--r-- 4.8 KiB
0bbb3759 — Eugene Blikh ci(apk): build CSS against core 0.84.5 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
}