v0.2.0 41cf2ec

tokens.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"crypto/rand"
8	"crypto/subtle"
9	"encoding/base64"
10	"encoding/hex"
11	"errors"
12	"fmt"
13	"slices"
14	"strings"
15	"time"
16	"unicode/utf8"
17
18	"github.com/bnema/zerowrap"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24// MaxTokenLabelLen is the longest token label, in bytes.
25const MaxTokenLabelLen = 100
26
27// reservedTokenLabels cannot label a token: they are what the audit trail
28// (author of a merge, "created_by" in the log) shows for the admin token and
29// for the owner signed in on the web, so a token with one of them would pass
30// for the owner.
31var reservedTokenLabels = []string{"owner", "root"}
32
33// touchInterval is how often last_used_at of a token is written at most.
34const touchInterval = time.Minute
35
36// Tokens are the use cases that manage stored tokens.
37type Tokens struct {
38	store    ports.TokenStore
39	clock    ports.Clock
40	sessions ports.SessionStore
41}
42
43// NewTokens builds the token use cases over their ports. sessions is where
44// revoking a token with owner rights ends every web session (see Revoke).
45func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.SessionStore) *Tokens {
46	return &Tokens{store: store, clock: clock, sessions: sessions}
47}
48
49// CreateTokenParams are the settings of a new token.
50type CreateTokenParams struct {
51	Label string
52	// Scopes must not be empty.
53	Scopes []domain.Scope
54	// Repos limits the token to these repositories; empty means every one.
55	Repos []domain.RepoName
56	// ExpiresAt is the zero time for a token that never expires.
57	ExpiresAt time.Time
58}
59
60// CreatedToken is a new token with its secret, which is shown once and never
61// stored.
62type CreatedToken struct {
63	Token  domain.Token
64	Secret string
65}
66
67// Create makes a stored token and returns it with its secret.
68//
69// The principal needs token:admin: anonymous callers get
70// domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
71// cannot create a token more powerful than itself: every scope must be one it
72// holds, the repositories must lie within its list (a limited token cannot
73// create an unlimited one) and the expiry must not outlast its own. Violations
74// give domain.ErrForbidden. Root has no such limits. Invalid settings give
75// domain.ErrInvalidToken (this includes the labels "owner" and "root", which
76// are reserved, in any case and around spaces), an unknown scope
77// domain.ErrInvalidScope.
78func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) {
79	if p.IsAnonymous() {
80		return CreatedToken{}, domain.ErrUnauthenticated
81	}
82	if !canManageTokens(p) {
83		return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
84	}
85	now := t.clock.Now().UTC()
86	tok, err := buildToken(params, now)
87	if err != nil {
88		return CreatedToken{}, err
89	}
90	if err := checkNoEscalation(p, tok); err != nil {
91		return CreatedToken{}, err
92	}
93
94	for range 3 {
95		secret := newSecret()
96		tok.ID, tok.Hash = newTokenID(), domain.HashTokenSecret(secret)
97		err := t.store.Create(ctx, tok)
98		if errors.Is(err, domain.ErrAlreadyExists) {
99			continue // a random id or secret collided: practically never
100		}
101		if err != nil {
102			return CreatedToken{}, fmt.Errorf("store token: %w", err)
103		}
104		log := zerowrap.FromCtx(ctx)
105		log.Info().
106			Str("token_id", tok.ID).Str("label", tok.Label).
107			Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
108			Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
109			Msg("token created")
110		return CreatedToken{Token: tok, Secret: secret}, nil
111	}
112	return CreatedToken{}, errors.New("store token: could not find a free id")
113}
114
115// buildToken validates the settings and returns the token without id and hash.
116func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) {
117	if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) {
118		return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen)
119	}
120	for _, r := range reservedTokenLabels {
121		if strings.EqualFold(strings.TrimSpace(params.Label), r) {
122			return domain.Token{}, fmt.Errorf("%w: label %q is reserved", domain.ErrInvalidToken, r)
123		}
124	}
125	if len(params.Scopes) == 0 {
126		return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken)
127	}
128	for _, s := range params.Scopes {
129		if _, err := domain.ParseScope(s.String()); err != nil {
130			return domain.Token{}, err
131		}
132	}
133	for _, r := range params.Repos {
134		if r.IsZero() {
135			return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken)
136		}
137	}
138	if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
139		return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
140	}
141	tok := domain.Token{
142		Label:     params.Label,
143		CreatedAt: now,
144		ExpiresAt: params.ExpiresAt.UTC(),
145	}
146	// Canonical order, no duplicates.
147	for _, s := range domain.AllScopes() {
148		if slices.Contains(params.Scopes, s) {
149			tok.Scopes = append(tok.Scopes, s)
150		}
151	}
152	for _, r := range params.Repos {
153		if !slices.Contains(tok.Repos, r) {
154			tok.Repos = append(tok.Repos, r)
155		}
156	}
157	slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
158	return tok, nil
159}
160
161// checkNoEscalation enforces that tok is within the rights of p: every scope
162// held by p, the repositories within its list and the expiry not beyond its
163// own. Root has no limits.
164func checkNoEscalation(p domain.Principal, tok domain.Token) error {
165	if p.IsRoot() {
166		return nil
167	}
168	for _, s := range tok.Scopes {
169		if !p.HasScope(s) {
170			return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s)
171		}
172	}
173	if own := p.Repos(); len(own) > 0 {
174		if len(tok.Repos) == 0 {
175			return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden)
176		}
177		for _, r := range tok.Repos {
178			if !slices.Contains(own, r) {
179				return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r)
180			}
181		}
182	}
183	if exp := p.ExpiresAt(); !exp.IsZero() {
184		if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
185			return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
186		}
187	}
188	return nil
189}
190
191// List returns the stored tokens the principal could have created, oldest
192// first: those whose scopes, repositories and expiry lie within its own (the
193// no-escalation rule of Create). Root sees every token. It needs token:admin.
194func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
195	if err := requireTokenAdmin(p); err != nil {
196		return nil, err
197	}
198	all, err := t.store.List(ctx)
199	if err != nil {
200		return nil, err
201	}
202	if p.IsRoot() {
203		return all, nil
204	}
205	return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil
206}
207
208// Revoke deletes a stored token; it stops working at once. It needs
209// token:admin and returns domain.ErrNotFound for an unknown id, and also for
210// a token more powerful than the principal (the no-escalation rule of
211// Create), so that it does not show itself. Root may revoke any token.
212//
213// A token that has owner rights (HasOwnerRights: every scope, no repository
214// list) could open web sessions, so revoking it also signs every browser out,
215// as `ermite web logout --all` does: web sessions are not tied to the token
216// that opened them. The revocation holds even if signing out fails, which is
217// then reported as an error.
218func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
219	if err := requireTokenAdmin(p); err != nil {
220		return err
221	}
222	tok, err := t.store.Get(ctx, id)
223	if err != nil {
224		return err
225	}
226	if !p.IsRoot() && checkNoEscalation(p, tok) != nil {
227		return domain.ErrNotFound
228	}
229	if err := t.store.Revoke(ctx, id); err != nil {
230		return err
231	}
232	log := zerowrap.FromCtx(ctx)
233	log.Info().Str("token_id", id).Str("revoked_by", p.Label()).Str("revoked_by_token", p.TokenID()).Msg("token revoked")
234	if !domain.TokenPrincipal(tok).HasOwnerRights() {
235		return nil
236	}
237	epoch, err := t.sessions.BumpEpoch(ctx)
238	if err != nil {
239		return fmt.Errorf("token %s revoked, but signing out the web sessions failed: %w", id, err)
240	}
241	log.Info().Str("token_id", id).Uint64("epoch", epoch).Msg("every web session signed out: the revoked token had owner rights")
242	return nil
243}
244
245func requireTokenAdmin(p domain.Principal) error {
246	if p.IsAnonymous() {
247		return domain.ErrUnauthenticated
248	}
249	if !canManageTokens(p) {
250		return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
251	}
252	return nil
253}
254
255// newSecret returns "ermite_" and 32 random bytes in base64url. crypto/rand
256// does not fail: it aborts the program if the system has no entropy.
257func newSecret() string {
258	var raw [32]byte
259	_, _ = rand.Read(raw[:])
260	return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:])
261}
262
263// newTokenID returns 16 hex characters.
264func newTokenID() string {
265	var raw [8]byte
266	_, _ = rand.Read(raw[:])
267	return hex.EncodeToString(raw[:])
268}
269
270// Auth authenticates secrets presented by callers.
271type Auth struct {
272	root   domain.TokenHash
273	tokens ports.TokenStore
274	clock  ports.Clock
275}
276
277// NewAuth builds the authentication use case. rootHash is the SHA-256 of the
278// admin token.
279func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
280	return &Auth{root: rootHash, tokens: tokens, clock: clock}
281}
282
283// Revalidate checks that the holder of a principal obtained earlier is still
284// authorized: root always is; a stored token must still exist and not have
285// expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
286// store as it is, so that callers fail closed. It is for long operations that
287// outlive the request that authenticated them, such as a push.
288func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
289	if p.IsRoot() {
290		return nil
291	}
292	if p.IsAnonymous() {
293		return domain.ErrUnauthenticated
294	}
295	tok, err := a.tokens.Get(ctx, p.TokenID())
296	if errors.Is(err, domain.ErrNotFound) {
297		return domain.ErrUnauthenticated
298	}
299	if err != nil {
300		return fmt.Errorf("find token: %w", err)
301	}
302	if tok.Expired(a.clock.Now().UTC()) {
303		return domain.ErrUnauthenticated
304	}
305	return nil
306}
307
308// Authenticate resolves a token secret to its principal: root when it is the
309// admin token (constant-time comparison of the hashes), else the stored token
310// with that hash if it has not expired. Any other secret, including the empty
311// one, gives domain.ErrUnauthenticated. last_used_at of a stored token is
312// written at most once a minute; a failure to write it is logged, not
313// returned.
314func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
315	if secret == "" {
316		return domain.Principal{}, domain.ErrUnauthenticated
317	}
318	h := domain.HashTokenSecret(secret)
319	if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
320		return domain.Root(), nil
321	}
322	tok, err := a.tokens.FindByHash(ctx, h)
323	if errors.Is(err, domain.ErrNotFound) {
324		return domain.Principal{}, domain.ErrUnauthenticated
325	}
326	if err != nil {
327		return domain.Principal{}, fmt.Errorf("find token: %w", err)
328	}
329	now := a.clock.Now().UTC()
330	if tok.Expired(now) {
331		return domain.Principal{}, domain.ErrUnauthenticated
332	}
333	if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval {
334		if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) {
335			log := zerowrap.FromCtx(ctx)
336			log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use")
337		}
338	}
339	return domain.TokenPrincipal(tok), nil
340}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close