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}
277
278var (
279 _ ports.AuthService = (*Auth)(nil)
280 _ ports.Revalidator = (*Auth)(nil)
281)
282
283// NewAuth builds the authentication use case. rootHash is the SHA-256 of the
284// admin token.
285func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth {
286 return &Auth{root: rootHash, tokens: tokens, clock: clock}
287}
288
289// Revalidate checks that the holder of a principal obtained earlier is still
290// authorized: root always is; a stored token must still exist and not have
291// expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
292// store as it is, so that callers fail closed. It is for long operations that
293// outlive the request that authenticated them, such as a push.
294func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
295 if p.IsRoot() {
296 return nil
297 }
298 if p.IsAnonymous() {
299 return domain.ErrUnauthenticated
300 }
301 tok, err := a.tokens.Get(ctx, p.TokenID())
302 if errors.Is(err, domain.ErrNotFound) {
303 return domain.ErrUnauthenticated
304 }
305 if err != nil {
306 return fmt.Errorf("find token: %w", err)
307 }
308 if tok.Expired(a.clock.Now().UTC()) {
309 return domain.ErrUnauthenticated
310 }
311 return nil
312}
313
314// Authenticate resolves a token secret to its principal: root when it is the
315// admin token (constant-time comparison of the hashes), else the stored token
316// with that hash if it has not expired. Any other secret, including the empty
317// one, gives domain.ErrUnauthenticated. last_used_at of a stored token is
318// written at most once a minute; a failure to write it is logged, not
319// returned.
320func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) {
321 if secret == "" {
322 return domain.Principal{}, domain.ErrUnauthenticated
323 }
324 h := domain.HashTokenSecret(secret)
325 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
326 return domain.Root(), nil
327 }
328 tok, err := a.tokens.FindByHash(ctx, h)
329 if errors.Is(err, domain.ErrNotFound) {
330 return domain.Principal{}, domain.ErrUnauthenticated
331 }
332 if err != nil {
333 return domain.Principal{}, fmt.Errorf("find token: %w", err)
334 }
335 now := a.clock.Now().UTC()
336 if tok.Expired(now) {
337 return domain.Principal{}, domain.ErrUnauthenticated
338 }
339 if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval {
340 if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) {
341 log := zerowrap.FromCtx(ctx)
342 log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use")
343 }
344 }
345 return domain.TokenPrincipal(tok), nil
346}