v0.1.0 3f267b5

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

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 44

↑↓ moveenter openesc close