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}