1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/app"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22type tokenFixture struct {
23 store *mocks.TokenStore
24 clock *mocks.Clock
25 tokens *app.Tokens
26 auth *app.Auth
27}
28
29var rootSecret = "root-secret-0123456789abcdef0123456789"
30
31func newTokenFixture(t *testing.T) *tokenFixture {
32 t.Helper()
33 f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t)}
34 f.clock.EXPECT().Now().Return(now).Maybe()
35 f.tokens = app.NewTokens(f.store, f.clock)
36 f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
37 return f
38}
39
40func scopes(s ...domain.Scope) []domain.Scope { return s }
41
42func names(t *testing.T, ss ...string) []domain.RepoName {
43 t.Helper()
44 var out []domain.RepoName
45 for _, s := range ss {
46 out = append(out, name(t, s))
47 }
48 return out
49}
50
51func TestTokensCreate(t *testing.T) {
52 ctx := context.Background()
53
54 t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
55 f := newTokenFixture(t)
56 var stored domain.Token
57 f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
58
59 exp := now.Add(48 * time.Hour)
60 got, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
61 Label: "ci",
62 Scopes: scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
63 Repos: names(t, "b", "a", "b"),
64 ExpiresAt: exp,
65 })
66 require.NoError(t, err)
67
68 assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
69 raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
70 require.NoError(t, err)
71 assert.Len(t, raw, 32)
72
73 assert.Equal(t, stored, got.Token)
74 assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
75 assert.Len(t, got.Token.ID, 16)
76 assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
77 assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
78 assert.Equal(t, now, got.Token.CreatedAt)
79 assert.True(t, exp.Equal(got.Token.ExpiresAt))
80 assert.NotContains(t, got.Token.Label, got.Secret)
81 })
82
83 t.Run("secrets are unique", func(t *testing.T) {
84 f := newTokenFixture(t)
85 f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
86 a, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
87 require.NoError(t, err)
88 b, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
89 require.NoError(t, err)
90 assert.NotEqual(t, a.Secret, b.Secret)
91 assert.NotEqual(t, a.Token.ID, b.Token.ID)
92 })
93
94 t.Run("retries on a collision", func(t *testing.T) {
95 f := newTokenFixture(t)
96 f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
97 f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
98 _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
99 require.NoError(t, err)
100 })
101
102 t.Run("store failure", func(t *testing.T) {
103 f := newTokenFixture(t)
104 boom := errors.New("boom")
105 f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
106 _, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
107 assert.ErrorIs(t, err, boom)
108 })
109
110 invalid := []struct {
111 name string
112 params app.CreateTokenParams
113 want error
114 }{
115 {"empty label", app.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
116 {"long label", app.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
117 {"bad utf8 label", app.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
118 {"no scopes", app.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
119 {"unknown scope", app.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
120 {"past expiry", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
121 {"zero repo name", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
122 }
123 for _, tt := range invalid {
124 t.Run("invalid/"+tt.name, func(t *testing.T) {
125 f := newTokenFixture(t)
126 _, err := f.tokens.Create(ctx, domain.Root(), tt.params)
127 assert.ErrorIs(t, err, tt.want)
128 })
129 }
130}
131
132func TestTokensCreateNoEscalation(t *testing.T) {
133 ctx := context.Background()
134 exp := now.Add(time.Hour)
135 admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
136 return domain.TokenPrincipal(domain.Token{
137 ID: "creator",
138 Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
139 Repos: repos,
140 ExpiresAt: expires,
141 })
142 }
143
144 tests := []struct {
145 name string
146 principal domain.Principal
147 params app.CreateTokenParams
148 want error // nil = allowed
149 }{
150 {"anonymous", domain.Anonymous(), app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
151 {"token without token:admin",
152 domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
153 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
154 {"subset of scopes", admin(nil, time.Time{}),
155 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
156 {"same scopes incl. token:admin", admin(nil, time.Time{}),
157 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
158 {"scope it lacks", admin(nil, time.Time{}),
159 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
160 {"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
161 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
162 {"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
163 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
164 {"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
165 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
166 {"unlimited creator may limit", admin(nil, time.Time{}),
167 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
168 {"expiring creator, child never expires", admin(nil, exp),
169 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
170 {"expiring creator, child outlasts", admin(nil, exp),
171 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
172 {"expiring creator, child within", admin(nil, exp),
173 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
174 {"expiring creator, child same expiry", admin(nil, exp),
175 app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
176 {"root: everything", domain.Root(),
177 app.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
178 }
179 for _, tt := range tests {
180 t.Run(tt.name, func(t *testing.T) {
181 f := newTokenFixture(t)
182 if tt.want == nil {
183 f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
184 }
185 got, err := f.tokens.Create(ctx, tt.principal, tt.params)
186 if tt.want != nil {
187 assert.ErrorIs(t, err, tt.want)
188 assert.Empty(t, got.Secret)
189 return
190 }
191 require.NoError(t, err)
192 assert.NotEmpty(t, got.Secret)
193 })
194 }
195}
196
197func TestTokensListRevoke(t *testing.T) {
198 ctx := context.Background()
199 tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
200 writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
201
202 t.Run("list for the admin token and root", func(t *testing.T) {
203 f := newTokenFixture(t)
204 want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
205 f.store.EXPECT().List(ctx).Return(want, nil).Twice()
206 for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
207 got, err := f.tokens.List(ctx, p)
208 require.NoError(t, err)
209 assert.Equal(t, want, got)
210 }
211 _, err := f.tokens.List(ctx, writer)
212 assert.ErrorIs(t, err, domain.ErrForbidden)
213 _, err = f.tokens.List(ctx, domain.Anonymous())
214 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
215 })
216
217 t.Run("revoke", func(t *testing.T) {
218 f := newTokenFixture(t)
219 f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once()
220 f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
221 f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once()
222 require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
223 require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
224 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
225 assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
226 assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
227 })
228}
229
230// A token admin sees and revokes only the tokens it could have created: the
231// no-escalation rule of Create, applied to scopes, repositories and expiry.
232func TestTokensAdminReach(t *testing.T) {
233 ctx := context.Background()
234 exp := now.Add(48 * time.Hour)
235 admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
236 return domain.TokenPrincipal(domain.Token{
237 ID: "caller",
238 Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
239 Repos: repos,
240 ExpiresAt: expires,
241 })
242 }
243 tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
244 return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
245 }
246 rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
247 ab, a := names(t, "a", "b"), names(t, "a")
248
249 tests := []struct {
250 name string
251 caller domain.Principal
252 target domain.Token
253 within bool
254 }{
255 {"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
256 {"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
257 {"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
258 {"root-only reach: a token:admin caller cannot reach a mr:merge token", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeMRMerge), nil, time.Time{}), false},
259 {"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
260 {"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
261 {"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
262 {"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
263 {"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
264 {"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
265 {"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
266 {"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
267 }
268 for _, tt := range tests {
269 t.Run("list: "+tt.name, func(t *testing.T) {
270 f := newTokenFixture(t)
271 other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
272 f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
273 got, err := f.tokens.List(ctx, tt.caller)
274 require.NoError(t, err)
275 if !tt.within {
276 // Only the token that is within reach of every caller shows.
277 assert.NotContains(t, got, tt.target)
278 return
279 }
280 assert.Contains(t, got, tt.target)
281 })
282 t.Run("revoke: "+tt.name, func(t *testing.T) {
283 f := newTokenFixture(t)
284 f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
285 if tt.within {
286 f.store.EXPECT().Revoke(ctx, "t").Return(nil)
287 require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
288 return
289 }
290 // Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
291 assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
292 })
293 }
294
295 t.Run("root lists and revokes everything", func(t *testing.T) {
296 f := newTokenFixture(t)
297 all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
298 f.store.EXPECT().List(ctx).Return(all, nil)
299 got, err := f.tokens.List(ctx, domain.Root())
300 require.NoError(t, err)
301 assert.Equal(t, all, got)
302 f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits
303 require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
304 })
305
306 t.Run("an unknown id is not found for everyone", func(t *testing.T) {
307 f := newTokenFixture(t)
308 f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
309 assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
310 })
311
312 t.Run("a store failure on the lookup is returned", func(t *testing.T) {
313 f := newTokenFixture(t)
314 boom := errors.New("boom")
315 f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
316 assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
317 })
318}
319
320func TestAuthRevalidate(t *testing.T) {
321 ctx := context.Background()
322 p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
323
324 t.Run("root is always valid", func(t *testing.T) {
325 f := newTokenFixture(t)
326 require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
327 })
328 t.Run("anonymous is not", func(t *testing.T) {
329 f := newTokenFixture(t)
330 assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
331 })
332 t.Run("a stored token that exists", func(t *testing.T) {
333 f := newTokenFixture(t)
334 f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
335 require.NoError(t, f.auth.Revalidate(ctx, p))
336 })
337 t.Run("revoked", func(t *testing.T) {
338 f := newTokenFixture(t)
339 f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
340 assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
341 })
342 t.Run("expired", func(t *testing.T) {
343 f := newTokenFixture(t)
344 f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
345 assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
346 })
347 t.Run("a store failure is an error, not a pass", func(t *testing.T) {
348 f := newTokenFixture(t)
349 boom := errors.New("boom")
350 f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
351 err := f.auth.Revalidate(ctx, p)
352 require.ErrorIs(t, err, boom)
353 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
354 })
355}
356
357func TestAuthenticate(t *testing.T) {
358 ctx := context.Background()
359 secret := "ermite_stored-secret"
360 hash := domain.HashTokenSecret(secret)
361 tok := domain.Token{
362 ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
363 ExpiresAt: now.Add(time.Hour),
364 }
365
366 t.Run("root", func(t *testing.T) {
367 f := newTokenFixture(t)
368 p, err := f.auth.Authenticate(ctx, rootSecret)
369 require.NoError(t, err)
370 assert.True(t, p.IsRoot())
371 })
372
373 t.Run("empty and unknown secrets", func(t *testing.T) {
374 f := newTokenFixture(t)
375 _, err := f.auth.Authenticate(ctx, "")
376 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
377 f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
378 _, err = f.auth.Authenticate(ctx, "nope")
379 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
380 })
381
382 t.Run("stored token first use touches", func(t *testing.T) {
383 f := newTokenFixture(t)
384 f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
385 f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
386 p, err := f.auth.Authenticate(ctx, secret)
387 require.NoError(t, err)
388 assert.Equal(t, "t1", p.TokenID())
389 assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
390 assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
391 })
392
393 t.Run("touch at most once a minute", func(t *testing.T) {
394 f := newTokenFixture(t)
395 recent := tok
396 recent.LastUsedAt = now.Add(-30 * time.Second)
397 f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
398 _, err := f.auth.Authenticate(ctx, secret) // no Touch expected
399 require.NoError(t, err)
400
401 f2 := newTokenFixture(t)
402 old := tok
403 old.LastUsedAt = now.Add(-time.Minute)
404 f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
405 f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
406 _, err = f2.auth.Authenticate(ctx, secret)
407 require.NoError(t, err)
408 })
409
410 t.Run("touch failure does not fail authentication", func(t *testing.T) {
411 f := newTokenFixture(t)
412 f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
413 f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
414 _, err := f.auth.Authenticate(ctx, secret)
415 require.NoError(t, err)
416 })
417
418 t.Run("expired", func(t *testing.T) {
419 f := newTokenFixture(t)
420 exp := tok
421 exp.ExpiresAt = now
422 f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
423 _, err := f.auth.Authenticate(ctx, secret)
424 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
425 })
426
427 t.Run("store error is not an authentication failure", func(t *testing.T) {
428 f := newTokenFixture(t)
429 boom := errors.New("boom")
430 f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
431 _, err := f.auth.Authenticate(ctx, secret)
432 assert.ErrorIs(t, err, boom)
433 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
434 })
435}