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}