v0.35.1 2aca1df

tokens.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
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
43var _ ports.TokenService = (*Tokens)(nil)
44
45// NewTokens builds the token use cases over their ports. sessions is where
46// revoking a token with owner rights ends every web session (see Revoke).
47func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.SessionStore) *Tokens {
48	return &Tokens{store: store, clock: clock, sessions: sessions}
49}
50
51// Create makes a stored token and returns it with its secret.
52//
53// The principal needs token:admin: anonymous callers get
54// domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
55// cannot create a token more powerful than itself: every scope must be one it
56// holds, the repositories must lie within its list (a limited token cannot
57// create an unlimited one), the same for branch prefixes (each must start
58// with one of its own) and the expiry must not outlast its own. Violations
59// give domain.ErrForbidden. Root has no such limits. Invalid settings give
60// domain.ErrInvalidToken (this includes the labels "owner" and "root", which
61// are reserved, in any case and around spaces), an unknown scope
62// domain.ErrInvalidScope.
63func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error) {
64	if p.IsAnonymous() {
65		return ports.CreatedToken{}, domain.ErrUnauthenticated
66	}
67	if !canManageTokens(p) {
68		return ports.CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
69	}
70	now := t.clock.Now().UTC()
71	tok, err := buildToken(params, now)
72	if err != nil {
73		return ports.CreatedToken{}, err
74	}
75	if err := checkNoEscalation(p, tok); err != nil {
76		return ports.CreatedToken{}, err
77	}
78
79	for range 3 {
80		secret := newSecret()
81		tok.ID, tok.Hash = newTokenID(), domain.HashTokenSecret(secret)
82		err := t.store.Create(ctx, tok)
83		if errors.Is(err, domain.ErrAlreadyExists) {
84			continue // a random id or secret collided: practically never
85		}
86		if err != nil {
87			return ports.CreatedToken{}, fmt.Errorf("store token: %w", err)
88		}
89		log := zerowrap.FromCtx(ctx)
90		log.Info().
91			Str("token_id", tok.ID).Str("label", tok.Label).
92			Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
93			Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
94			Msg("token created")
95		return ports.CreatedToken{Token: tok, Secret: secret}, nil
96	}
97	return ports.CreatedToken{}, errors.New("store token: could not find a free id")
98}
99
100// buildToken validates the settings and returns the token without id and hash.
101func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, error) {
102	if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) {
103		return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen)
104	}
105	for _, r := range reservedTokenLabels {
106		if strings.EqualFold(strings.TrimSpace(params.Label), r) {
107			return domain.Token{}, fmt.Errorf("%w: label %q is reserved", domain.ErrInvalidToken, r)
108		}
109	}
110	if len(params.Scopes) == 0 {
111		return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken)
112	}
113	for _, s := range params.Scopes {
114		if _, err := domain.ParseScope(s.String()); err != nil {
115			return domain.Token{}, err
116		}
117	}
118	for _, r := range params.Repos {
119		if r.IsZero() {
120			return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken)
121		}
122	}
123	if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
124		return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
125	}
126	prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127	if err != nil {
128		return domain.Token{}, err
129	}
130	tok := domain.Token{
131		Label:          params.Label,
132		BranchPrefixes: prefixes,
133		CreatedAt:      now,
134		ExpiresAt:      params.ExpiresAt.UTC(),
135	}
136	// Canonical order, no duplicates.
137	for _, s := range domain.AllScopes() {
138		if slices.Contains(params.Scopes, s) {
139			tok.Scopes = append(tok.Scopes, s)
140		}
141	}
142	for _, r := range params.Repos {
143		if !slices.Contains(tok.Repos, r) {
144			tok.Repos = append(tok.Repos, r)
145		}
146	}
147	slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
148	return tok, nil
149}
150
151// checkNoEscalation enforces that tok is within the rights of p: every scope
152// held by p, the repositories within its list, the branch prefixes within its
153// own (a limited p only makes tokens that are limited too, each prefix under
154// one of its own) and the expiry not beyond its own. Root has no limits.
155func checkNoEscalation(p domain.Principal, tok domain.Token) error {
156	if p.IsRoot() {
157		return nil
158	}
159	for _, s := range tok.Scopes {
160		if !p.HasScope(s) {
161			return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s)
162		}
163	}
164	if own := p.Repos(); len(own) > 0 {
165		if len(tok.Repos) == 0 {
166			return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden)
167		}
168		for _, r := range tok.Repos {
169			if !slices.Contains(own, r) {
170				return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r)
171			}
172		}
173	}
174	if p.IsBranchLimited() {
175		if len(tok.BranchPrefixes) == 0 {
176			return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
177		}
178		for _, bp := range tok.BranchPrefixes {
179			if !p.CoversBranchPrefix(bp) {
180				return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
181			}
182		}
183	}
184	if exp := p.ExpiresAt(); !exp.IsZero() {
185		if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
186			return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
187		}
188	}
189	return nil
190}
191
192// List returns the stored tokens the principal could have created, oldest
193// first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the
194// no-escalation rule of Create). Root sees every token. It needs token:admin.
195func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
196	if err := requireTokenAdmin(p); err != nil {
197		return nil, err
198	}
199	all, err := t.store.List(ctx)
200	if err != nil {
201		return nil, err
202	}
203	if p.IsRoot() {
204		return all, nil
205	}
206	return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil
207}
208
209// Revoke deletes a stored token; it stops working at once. It needs
210// token:admin and returns domain.ErrNotFound for an unknown id, and also for
211// a token more powerful than the principal (the no-escalation rule of
212// Create), so that it does not show itself. Root may revoke any token.
213//
214// A token that has owner rights (HasOwnerRights: every scope, no repository
215// list and no branch prefixes) could open web sessions, so revoking it also signs every browser out,
216// as `ermite web logout --all` does: web sessions are not tied to the token
217// that opened them. The revocation holds even if signing out fails, which is
218// then reported as an error.
219func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error {
220	if err := requireTokenAdmin(p); err != nil {
221		return err
222	}
223	tok, err := t.store.Get(ctx, id)
224	if err != nil {
225		return err
226	}
227	if !p.IsRoot() && checkNoEscalation(p, tok) != nil {
228		return domain.ErrNotFound
229	}
230	if err := t.store.Revoke(ctx, id); err != nil {
231		return err
232	}
233	log := zerowrap.FromCtx(ctx)
234	log.Info().Str("token_id", id).Str("revoked_by", p.Label()).Str("revoked_by_token", p.TokenID()).Msg("token revoked")
235	if !domain.TokenPrincipal(tok).HasOwnerRights() {
236		return nil
237	}
238	epoch, err := t.sessions.BumpEpoch(ctx)
239	if err != nil {
240		return fmt.Errorf("token %s revoked, but signing out the web sessions failed: %w", id, err)
241	}
242	log.Info().Str("token_id", id).Uint64("epoch", epoch).Msg("every web session signed out: the revoked token had owner rights")
243	return nil
244}
245
246func requireTokenAdmin(p domain.Principal) error {
247	if p.IsAnonymous() {
248		return domain.ErrUnauthenticated
249	}
250	if !canManageTokens(p) {
251		return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin)
252	}
253	return nil
254}
255
256// newSecret returns "ermite_" and 32 random bytes in base64url. crypto/rand
257// does not fail: it aborts the program if the system has no entropy.
258func newSecret() string {
259	var raw [32]byte
260	_, _ = rand.Read(raw[:])
261	return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:])
262}
263
264// newTokenID returns 16 hex characters.
265func newTokenID() string {
266	var raw [8]byte
267	_, _ = rand.Read(raw[:])
268	return hex.EncodeToString(raw[:])
269}
270
271// Auth authenticates secrets presented by callers.
272type Auth struct {
273	root   domain.TokenHash
274	tokens ports.TokenStore
275	clock  ports.Clock
276	// repoTokens resolves the job repository tokens (domain.RepoTokenPrefix);
277	// nil when CI is not wired.
278	repoTokens ports.CIRepoTokenStore
279}
280
281var (
282	_ ports.AuthService = (*Auth)(nil)
283	_ ports.Revalidator = (*Auth)(nil)
284)
285
286// NewAuth builds the authentication use case. rootHash is the SHA-256 of the
287// admin token.
288func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
289	return &Auth{root: rootHash, tokens: tokens, clock: clock}
290}
291
292// WithRepoTokens makes Authenticate accept the job repository tokens of CI
293// (the "github.token" of a job, ADR 0025). They resolve to a principal shaped
294// like a stored token (repo:read on one repository, with an expiry), so no
295// rule of the forge knows them; they are stored apart from the stored tokens,
296// so ListTokens never shows them and token:admin never manages them. Call it
297// once while wiring.
298func (a *Auth) WithRepoTokens(s ports.CIRepoTokenStore) *Auth {
299	a.repoTokens = s
300	return a
301}
302
303// Revalidate checks that the holder of a principal obtained earlier is still
304// authorized: root always is; a stored token must still exist and not have
305// expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
306// store as it is, so that callers fail closed. It is for long operations that
307// outlive the request that authenticated them, such as a push.
308func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
309	if p.IsRoot() {
310		return nil
311	}
312	if p.IsAnonymous() {
313		return domain.ErrUnauthenticated
314	}
315	if id := p.TokenID(); domain.IsRepoTokenID(id) {
316		return a.revalidateRepoToken(ctx, strings.TrimPrefix(id, "job:"))
317	}
318	tok, err := a.tokens.Get(ctx, p.TokenID())
319	if errors.Is(err, domain.ErrNotFound) {
320		return domain.ErrUnauthenticated
321	}
322	if err != nil {
323		return fmt.Errorf("find token: %w", err)
324	}
325	if tok.Expired(a.clock.Now().UTC()) {
326		return domain.ErrUnauthenticated
327	}
328	return nil
329}
330
331// Authenticate resolves a token secret to its principal: root when it is the
332// admin token (constant-time comparison of the hashes), else the stored token
333// with that hash if it has not expired. Any other secret, including the empty
334// one, gives domain.ErrUnauthenticated. last_used_at of a stored token is
335// written at most once a minute; a failure to write it is logged, not
336// returned.
337func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
338	if secret == "" {
339		return domain.Principal{}, domain.ErrUnauthenticated
340	}
341	h := domain.HashTokenSecret(secret)
342	if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
343		return domain.Root(), nil
344	}
345	if domain.IsRepoToken(secret) {
346		return a.authenticateRepoToken(ctx, h)
347	}
348	tok, err := a.tokens.FindByHash(ctx, h)
349	if errors.Is(err, domain.ErrNotFound) {
350		return domain.Principal{}, domain.ErrUnauthenticated
351	}
352	if err != nil {
353		return domain.Principal{}, fmt.Errorf("find token: %w", err)
354	}
355	now := a.clock.Now().UTC()
356	if tok.Expired(now) {
357		return domain.Principal{}, domain.ErrUnauthenticated
358	}
359	if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval {
360		if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) {
361			log := zerowrap.FromCtx(ctx)
362			log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use")
363		}
364	}
365	return domain.TokenPrincipal(tok), nil
366}
367
368// authenticateRepoToken resolves a job repository token: it exists (it is
369// deleted when its job ends, is cancelled or lost) and has not expired.
370func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (domain.Principal, error) {
371	if a.repoTokens == nil {
372		return domain.Principal{}, domain.ErrUnauthenticated
373	}
374	t, err := a.repoTokens.FindRepoTokenByHash(ctx, h)
375	if errors.Is(err, domain.ErrNotFound) {
376		return domain.Principal{}, domain.ErrUnauthenticated
377	}
378	if err != nil {
379		return domain.Principal{}, fmt.Errorf("find repository token: %w", err)
380	}
381	if t.OIDC || t.Expired(a.clock.Now().UTC()) {
382		// An ID token request token is only for the token endpoint.
383		return domain.Principal{}, domain.ErrUnauthenticated
384	}
385	return domain.RepoTokenPrincipal(t), nil
386}
387
388// revalidateRepoToken is Revalidate for the principal of a job repository
389// token.
390func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error {
391	if a.repoTokens == nil {
392		return domain.ErrUnauthenticated
393	}
394	t, err := a.repoTokens.GetRepoToken(ctx, id)
395	if errors.Is(err, domain.ErrNotFound) {
396		return domain.ErrUnauthenticated
397	}
398	if err != nil {
399		return fmt.Errorf("find repository token: %w", err)
400	}
401	if t.OIDC || t.Expired(a.clock.Now().UTC()) {
402		return domain.ErrUnauthenticated
403	}
404	return nil
405}

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 4

Tags 45

↑↓ moveenter openesc close