feat(app): add token use cases, authentication and token-aware access rule
- commit
- 2bea74e367e573503537a3b68aeea291ea0052f0
- author
- brice · · 2026-10-10 06:42 UTC
- committer
- brice · · 2026-10-10 07:03 UTC
- parent
- 607c375
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +662 -12
M internal/adapters/kvstore/tokens.go +6 -1
| @@ -139,7 +139,10 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error { | |||
| 139 | 139 | return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict) | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | - | // Touch sets last_used_at when the given time is later than the stored one. | |
| 142 | + | // Touch sets last_used_at when the given time is later than the stored one, | |
| 143 | + | // so that racing writers never move it back. | |
| 144 | + | // | |
| 145 | + | //hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy | |
| 143 | 146 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |
| 144 | 147 | if !validTokenID(id) { | |
| 145 | 148 | return domain.ErrNotFound | |
| @@ -211,6 +214,8 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||
| 211 | 214 | ||
| 212 | 215 | // decodeToken drops scopes it does not know (written by a newer ermite): a | |
| 213 | 216 | // token never gains rights by being read by an older build. | |
| 217 | + | // | |
| 218 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 214 | 219 | func decodeToken(val []byte) (domain.Token, error) { | |
| 215 | 220 | var pb storagepb.Token | |
| 216 | 221 | if err := proto.Unmarshal(val, &pb); err != nil { | |
| @@ -139,7 +139,10 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error { | |||||
| 139 | return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict) | 139 | return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict) | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | - | // Touch sets last_used_at when the given time is later than the stored one. | 142 | + | // Touch sets last_used_at when the given time is later than the stored one, |
| 143 | + | // so that racing writers never move it back. | |||
| 144 | + | // | |||
| 145 | + | //hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy | |||
| 143 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | 146 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | ||
| 144 | if !validTokenID(id) { | 147 | if !validTokenID(id) { | ||
| 145 | return domain.ErrNotFound | 148 | return domain.ErrNotFound | ||
| @@ -211,6 +214,8 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||||
| 211 | 214 | ||||
| 212 | // decodeToken drops scopes it does not know (written by a newer ermite): a | 215 | // decodeToken drops scopes it does not know (written by a newer ermite): a | ||
| 213 | // token never gains rights by being read by an older build. | 216 | // token never gains rights by being read by an older build. | ||
| 217 | + | // | |||
| 218 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 214 | func decodeToken(val []byte) (domain.Token, error) { | 219 | func decodeToken(val []byte) (domain.Token, error) { | ||
| 215 | var pb storagepb.Token | 220 | var pb storagepb.Token | ||
| 216 | if err := proto.Unmarshal(val, &pb); err != nil { | 221 | if err := proto.Unmarshal(val, &pb); err != nil { | ||
M internal/app/access.go +17 -7
| @@ -5,15 +5,25 @@ package app | |||
| 5 | 5 | import "bnema.dev/ermite/internal/domain" | |
| 6 | 6 | ||
| 7 | 7 | // canRead is the access rule for reading a repository, the single place that | |
| 8 | - | // decides it: root reads everything, anonymous callers read public | |
| 9 | - | // repositories only. Callers answer domain.ErrNotFound when it is false, so | |
| 10 | - | // a private repository never shows itself. | |
| 8 | + | // decides it: root reads everything, anyone reads a public repository, and a | |
| 9 | + | // stored token reads a private one only with repo:read on it (the scope, and | |
| 10 | + | // the repository within its list). Callers answer domain.ErrNotFound when it | |
| 11 | + | // is false, so a private repository never shows itself. | |
| 11 | 12 | func canRead(p domain.Principal, repo domain.Repo) bool { | |
| 12 | - | if p.IsRoot() { | |
| 13 | + | if repo.Visibility == domain.VisibilityPublic { | |
| 13 | 14 | return true | |
| 14 | 15 | } | |
| 15 | - | return repo.Visibility == domain.VisibilityPublic | |
| 16 | + | return p.Allows(domain.ScopeRepoRead, repo.Name) | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | - | // canCreate is the access rule for creating a repository: root only. | |
| 19 | - | func canCreate(p domain.Principal) bool { return p.IsRoot() } | |
| 19 | + | // canWrite is the access rule for pushing to a repository, or creating it on | |
| 20 | + | // first push: repo:write on it. | |
| 21 | + | func canWrite(p domain.Principal, name domain.RepoName) bool { | |
| 22 | + | return p.Allows(domain.ScopeRepoWrite, name) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // canManageTokens is the access rule for creating, listing and revoking | |
| 26 | + | // stored tokens: token:admin, which root holds. | |
| 27 | + | func canManageTokens(p domain.Principal) bool { | |
| 28 | + | return p.HasScope(domain.ScopeTokenAdmin) | |
| 29 | + | } | |
| @@ -5,15 +5,25 @@ package app | |||||
| 5 | import "bnema.dev/ermite/internal/domain" | 5 | import "bnema.dev/ermite/internal/domain" | ||
| 6 | 6 | ||||
| 7 | // canRead is the access rule for reading a repository, the single place that | 7 | // canRead is the access rule for reading a repository, the single place that | ||
| 8 | - | // decides it: root reads everything, anonymous callers read public | 8 | + | // decides it: root reads everything, anyone reads a public repository, and a |
| 9 | - | // repositories only. Callers answer domain.ErrNotFound when it is false, so | 9 | + | // stored token reads a private one only with repo:read on it (the scope, and |
| 10 | - | // a private repository never shows itself. | 10 | + | // the repository within its list). Callers answer domain.ErrNotFound when it |
| 11 | + | // is false, so a private repository never shows itself. | |||
| 11 | func canRead(p domain.Principal, repo domain.Repo) bool { | 12 | func canRead(p domain.Principal, repo domain.Repo) bool { | ||
| 12 | - | if p.IsRoot() { | 13 | + | if repo.Visibility == domain.VisibilityPublic { |
| 13 | return true | 14 | return true | ||
| 14 | } | 15 | } | ||
| 15 | - | return repo.Visibility == domain.VisibilityPublic | 16 | + | return p.Allows(domain.ScopeRepoRead, repo.Name) |
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | - | // canCreate is the access rule for creating a repository: root only. | 19 | + | // canWrite is the access rule for pushing to a repository, or creating it on |
| 19 | - | func canCreate(p domain.Principal) bool { return p.IsRoot() } | 20 | + | // first push: repo:write on it. |
| 21 | + | func canWrite(p domain.Principal, name domain.RepoName) bool { | |||
| 22 | + | return p.Allows(domain.ScopeRepoWrite, name) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // canManageTokens is the access rule for creating, listing and revoking | |||
| 26 | + | // stored tokens: token:admin, which root holds. | |||
| 27 | + | func canManageTokens(p domain.Principal) bool { | |||
| 28 | + | return p.HasScope(domain.ScopeTokenAdmin) | |||
| 29 | + | } | |||
M internal/app/repos.go +8 -4
| @@ -41,14 +41,18 @@ type CreateParams struct { | |||
| 41 | 41 | // after the metadata is written removes it again and discards the staged | |
| 42 | 42 | // repository. | |
| 43 | 43 | // | |
| 44 | - | // Only root may create; anonymous callers get domain.ErrUnauthenticated. | |
| 44 | + | // The principal needs repo:write on the name: anonymous callers get | |
| 45 | + | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | |
| 45 | 46 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | |
| 46 | - | if !canCreate(p) { | |
| 47 | - | return domain.Repo{}, domain.ErrUnauthenticated | |
| 48 | - | } | |
| 49 | 47 | if params.Name.IsZero() { | |
| 50 | 48 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 51 | 49 | } | |
| 50 | + | if p.IsAnonymous() { | |
| 51 | + | return domain.Repo{}, domain.ErrUnauthenticated | |
| 52 | + | } | |
| 53 | + | if !canWrite(p, params.Name) { | |
| 54 | + | return domain.Repo{}, domain.ErrForbidden | |
| 55 | + | } | |
| 52 | 56 | vis := params.Visibility | |
| 53 | 57 | if vis == "" { | |
| 54 | 58 | vis = domain.VisibilityPrivate | |
| @@ -41,14 +41,18 @@ type CreateParams struct { | |||||
| 41 | // after the metadata is written removes it again and discards the staged | 41 | // after the metadata is written removes it again and discards the staged | ||
| 42 | // repository. | 42 | // repository. | ||
| 43 | // | 43 | // | ||
| 44 | - | // Only root may create; anonymous callers get domain.ErrUnauthenticated. | 44 | + | // The principal needs repo:write on the name: anonymous callers get |
| 45 | + | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | |||
| 45 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | 46 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | ||
| 46 | - | if !canCreate(p) { | |||
| 47 | - | return domain.Repo{}, domain.ErrUnauthenticated | |||
| 48 | - | } | |||
| 49 | if params.Name.IsZero() { | 47 | if params.Name.IsZero() { | ||
| 50 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | 48 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | ||
| 51 | } | 49 | } | ||
| 50 | + | if p.IsAnonymous() { | |||
| 51 | + | return domain.Repo{}, domain.ErrUnauthenticated | |||
| 52 | + | } | |||
| 53 | + | if !canWrite(p, params.Name) { | |||
| 54 | + | return domain.Repo{}, domain.ErrForbidden | |||
| 55 | + | } | |||
| 52 | vis := params.Visibility | 56 | vis := params.Visibility | ||
| 53 | if vis == "" { | 57 | if vis == "" { | ||
| 54 | vis = domain.VisibilityPrivate | 58 | vis = domain.VisibilityPrivate | ||
M internal/app/repos_test.go +39 -0
| @@ -69,6 +69,27 @@ func TestCreate(t *testing.T) { | |||
| 69 | 69 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 70 | 70 | }) | |
| 71 | 71 | ||
| 72 | + | t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) { | |
| 73 | + | for _, p := range []domain.Principal{ | |
| 74 | + | tokenPrincipal(domain.ScopeRepoRead), | |
| 75 | + | tokenPrincipal(domain.ScopeRepoWrite, "other"), | |
| 76 | + | } { | |
| 77 | + | f := newFixture(t) | |
| 78 | + | _, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")}) | |
| 79 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | ||
| 83 | + | t.Run("token with repo:write creates", func(t *testing.T) { | |
| 84 | + | f := newFixture(t) | |
| 85 | + | n := name(t, "tools") | |
| 86 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |
| 87 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 88 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |
| 89 | + | _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n}) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | }) | |
| 92 | + | ||
| 72 | 93 | t.Run("zero name is invalid", func(t *testing.T) { | |
| 73 | 94 | f := newFixture(t) | |
| 74 | 95 | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{}) | |
| @@ -143,6 +164,18 @@ func TestCreate(t *testing.T) { | |||
| 143 | 164 | }) | |
| 144 | 165 | } | |
| 145 | 166 | ||
| 167 | + | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |
| 168 | + | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}} | |
| 169 | + | for _, r := range repos { | |
| 170 | + | n, err := domain.ParseRepoName(r) | |
| 171 | + | if err != nil { | |
| 172 | + | panic(err) | |
| 173 | + | } | |
| 174 | + | tok.Repos = append(tok.Repos, n) | |
| 175 | + | } | |
| 176 | + | return domain.TokenPrincipal(tok) | |
| 177 | + | } | |
| 178 | + | ||
| 146 | 179 | func TestGetAndListAccess(t *testing.T) { | |
| 147 | 180 | ctx := context.Background() | |
| 148 | 181 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| @@ -158,6 +191,12 @@ func TestGetAndListAccess(t *testing.T) { | |||
| 158 | 191 | {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound}, | |
| 159 | 192 | {"root reads public", domain.Root(), pub, nil}, | |
| 160 | 193 | {"root reads private", domain.Root(), priv, nil}, | |
| 194 | + | {"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil}, | |
| 195 | + | {"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil}, | |
| 196 | + | {"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound}, | |
| 197 | + | {"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound}, | |
| 198 | + | {"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil}, | |
| 199 | + | {"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil}, | |
| 161 | 200 | } | |
| 162 | 201 | for _, tt := range tests { | |
| 163 | 202 | t.Run("get/"+tt.name, func(t *testing.T) { | |
| @@ -69,6 +69,27 @@ func TestCreate(t *testing.T) { | |||||
| 69 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | 69 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | ||
| 70 | }) | 70 | }) | ||
| 71 | 71 | ||||
| 72 | + | t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) { | |||
| 73 | + | for _, p := range []domain.Principal{ | |||
| 74 | + | tokenPrincipal(domain.ScopeRepoRead), | |||
| 75 | + | tokenPrincipal(domain.ScopeRepoWrite, "other"), | |||
| 76 | + | } { | |||
| 77 | + | f := newFixture(t) | |||
| 78 | + | _, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")}) | |||
| 79 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | ||||
| 83 | + | t.Run("token with repo:write creates", func(t *testing.T) { | |||
| 84 | + | f := newFixture(t) | |||
| 85 | + | n := name(t, "tools") | |||
| 86 | + | f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil) | |||
| 87 | + | f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil) | |||
| 88 | + | f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil) | |||
| 89 | + | _, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n}) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | }) | |||
| 92 | + | ||||
| 72 | t.Run("zero name is invalid", func(t *testing.T) { | 93 | t.Run("zero name is invalid", func(t *testing.T) { | ||
| 73 | f := newFixture(t) | 94 | f := newFixture(t) | ||
| 74 | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{}) | 95 | _, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{}) | ||
| @@ -143,6 +164,18 @@ func TestCreate(t *testing.T) { | |||||
| 143 | }) | 164 | }) | ||
| 144 | } | 165 | } | ||
| 145 | 166 | ||||
| 167 | + | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |||
| 168 | + | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}} | |||
| 169 | + | for _, r := range repos { | |||
| 170 | + | n, err := domain.ParseRepoName(r) | |||
| 171 | + | if err != nil { | |||
| 172 | + | panic(err) | |||
| 173 | + | } | |||
| 174 | + | tok.Repos = append(tok.Repos, n) | |||
| 175 | + | } | |||
| 176 | + | return domain.TokenPrincipal(tok) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 146 | func TestGetAndListAccess(t *testing.T) { | 179 | func TestGetAndListAccess(t *testing.T) { | ||
| 147 | ctx := context.Background() | 180 | ctx := context.Background() | ||
| 148 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | 181 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | ||
| @@ -158,6 +191,12 @@ func TestGetAndListAccess(t *testing.T) { | |||||
| 158 | {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound}, | 191 | {"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound}, | ||
| 159 | {"root reads public", domain.Root(), pub, nil}, | 192 | {"root reads public", domain.Root(), pub, nil}, | ||
| 160 | {"root reads private", domain.Root(), priv, nil}, | 193 | {"root reads private", domain.Root(), priv, nil}, | ||
| 194 | + | {"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil}, | |||
| 195 | + | {"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil}, | |||
| 196 | + | {"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound}, | |||
| 197 | + | {"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound}, | |||
| 198 | + | {"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil}, | |||
| 199 | + | {"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil}, | |||
| 161 | } | 200 | } | ||
| 162 | for _, tt := range tests { | 201 | for _, tt := range tests { | ||
| 163 | t.Run("get/"+tt.name, func(t *testing.T) { | 202 | t.Run("get/"+tt.name, func(t *testing.T) { | ||
A internal/app/tokens.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "crypto/subtle" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "encoding/hex" | |
| 11 | + | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "slices" | |
| 14 | + | "time" | |
| 15 | + | "unicode/utf8" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/zerowrap" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // MaxTokenLabelLen is the longest token label, in bytes. | |
| 24 | + | const MaxTokenLabelLen = 100 | |
| 25 | + | ||
| 26 | + | // touchInterval is how often last_used_at of a token is written at most. | |
| 27 | + | const touchInterval = time.Minute | |
| 28 | + | ||
| 29 | + | // Tokens are the use cases that manage stored tokens. | |
| 30 | + | type Tokens struct { | |
| 31 | + | store ports.TokenStore | |
| 32 | + | clock ports.Clock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // NewTokens builds the token use cases over their ports. | |
| 36 | + | func NewTokens(store ports.TokenStore, clock ports.Clock) *Tokens { | |
| 37 | + | return &Tokens{store: store, clock: clock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // CreateTokenParams are the settings of a new token. | |
| 41 | + | type CreateTokenParams struct { | |
| 42 | + | Label string | |
| 43 | + | // Scopes must not be empty. | |
| 44 | + | Scopes []domain.Scope | |
| 45 | + | // Repos limits the token to these repositories; empty means every one. | |
| 46 | + | Repos []domain.RepoName | |
| 47 | + | // ExpiresAt is the zero time for a token that never expires. | |
| 48 | + | ExpiresAt time.Time | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // CreatedToken is a new token with its secret, which is shown once and never | |
| 52 | + | // stored. | |
| 53 | + | type CreatedToken struct { | |
| 54 | + | Token domain.Token | |
| 55 | + | Secret string | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Create makes a stored token and returns it with its secret. | |
| 59 | + | // | |
| 60 | + | // The principal needs token:admin: anonymous callers get | |
| 61 | + | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | |
| 62 | + | // cannot create a token more powerful than itself: every scope must be one it | |
| 63 | + | // holds, the repositories must lie within its list (a limited token cannot | |
| 64 | + | // create an unlimited one) and the expiry must not outlast its own. Violations | |
| 65 | + | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | |
| 66 | + | // domain.ErrInvalidToken, an unknown scope domain.ErrInvalidScope. | |
| 67 | + | func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) { | |
| 68 | + | if p.IsAnonymous() { | |
| 69 | + | return CreatedToken{}, domain.ErrUnauthenticated | |
| 70 | + | } | |
| 71 | + | if !canManageTokens(p) { | |
| 72 | + | return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin) | |
| 73 | + | } | |
| 74 | + | now := t.clock.Now().UTC() | |
| 75 | + | tok, err := buildToken(params, now) | |
| 76 | + | if err != nil { | |
| 77 | + | return CreatedToken{}, err | |
| 78 | + | } | |
| 79 | + | if err := checkNoEscalation(p, tok); err != nil { | |
| 80 | + | return CreatedToken{}, err | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | for range 3 { | |
| 84 | + | secret, err := newSecret() | |
| 85 | + | if err != nil { | |
| 86 | + | return CreatedToken{}, err | |
| 87 | + | } | |
| 88 | + | id, err := newTokenID() | |
| 89 | + | if err != nil { | |
| 90 | + | return CreatedToken{}, err | |
| 91 | + | } | |
| 92 | + | tok.ID, tok.Hash = id, domain.HashTokenSecret(secret) | |
| 93 | + | err = t.store.Create(ctx, tok) | |
| 94 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 95 | + | continue // a random id or secret collided: practically never | |
| 96 | + | } | |
| 97 | + | if err != nil { | |
| 98 | + | return CreatedToken{}, fmt.Errorf("store token: %w", err) | |
| 99 | + | } | |
| 100 | + | log := zerowrap.FromCtx(ctx) | |
| 101 | + | log.Info(). | |
| 102 | + | Str("token_id", tok.ID).Str("label", tok.Label). | |
| 103 | + | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)). | |
| 104 | + | Str("created_by", p.Kind().String()).Str("created_by_token", p.TokenID()). | |
| 105 | + | Msg("token created") | |
| 106 | + | return CreatedToken{Token: tok, Secret: secret}, nil | |
| 107 | + | } | |
| 108 | + | return CreatedToken{}, errors.New("store token: could not find a free id") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // buildToken validates the settings and returns the token without id and hash. | |
| 112 | + | func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) { | |
| 113 | + | if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) { | |
| 114 | + | return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen) | |
| 115 | + | } | |
| 116 | + | if len(params.Scopes) == 0 { | |
| 117 | + | return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken) | |
| 118 | + | } | |
| 119 | + | for _, s := range params.Scopes { | |
| 120 | + | if _, err := domain.ParseScope(s.String()); err != nil { | |
| 121 | + | return domain.Token{}, err | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | for _, r := range params.Repos { | |
| 125 | + | if r.IsZero() { | |
| 126 | + | return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | |
| 130 | + | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | |
| 131 | + | } | |
| 132 | + | tok := domain.Token{ | |
| 133 | + | Label: params.Label, | |
| 134 | + | CreatedAt: now, | |
| 135 | + | ExpiresAt: params.ExpiresAt.UTC(), | |
| 136 | + | } | |
| 137 | + | // Canonical order, no duplicates. | |
| 138 | + | for _, s := range domain.AllScopes() { | |
| 139 | + | if slices.Contains(params.Scopes, s) { | |
| 140 | + | tok.Scopes = append(tok.Scopes, s) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | for _, r := range params.Repos { | |
| 144 | + | if !slices.Contains(tok.Repos, r) { | |
| 145 | + | tok.Repos = append(tok.Repos, r) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return compareNames(a, b) }) | |
| 149 | + | return tok, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func compareNames(a, b domain.RepoName) int { | |
| 153 | + | switch { | |
| 154 | + | case a.String() < b.String(): | |
| 155 | + | return -1 | |
| 156 | + | case a.String() > b.String(): | |
| 157 | + | return 1 | |
| 158 | + | } | |
| 159 | + | return 0 | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // checkNoEscalation enforces that tok is within the rights of its creator. | |
| 163 | + | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |
| 164 | + | if p.IsRoot() { | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | for _, s := range tok.Scopes { | |
| 168 | + | if !p.HasScope(s) { | |
| 169 | + | return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | if own := p.Repos(); len(own) > 0 { | |
| 173 | + | if len(tok.Repos) == 0 { | |
| 174 | + | return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden) | |
| 175 | + | } | |
| 176 | + | for _, r := range tok.Repos { | |
| 177 | + | if !slices.Contains(own, r) { | |
| 178 | + | return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | if exp := p.ExpiresAt(); !exp.IsZero() { | |
| 183 | + | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | |
| 184 | + | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // List returns the stored tokens, oldest first. It needs token:admin. | |
| 191 | + | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | |
| 192 | + | if err := requireTokenAdmin(p); err != nil { | |
| 193 | + | return nil, err | |
| 194 | + | } | |
| 195 | + | return t.store.List(ctx) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Revoke deletes a stored token; it stops working at once. It needs | |
| 199 | + | // token:admin and returns domain.ErrNotFound for an unknown id. | |
| 200 | + | func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error { | |
| 201 | + | if err := requireTokenAdmin(p); err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if err := t.store.Revoke(ctx, id); err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | log := zerowrap.FromCtx(ctx) | |
| 208 | + | log.Info().Str("token_id", id).Str("revoked_by", p.Kind().String()).Str("revoked_by_token", p.TokenID()).Msg("token revoked") | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func requireTokenAdmin(p domain.Principal) error { | |
| 213 | + | if p.IsAnonymous() { | |
| 214 | + | return domain.ErrUnauthenticated | |
| 215 | + | } | |
| 216 | + | if !canManageTokens(p) { | |
| 217 | + | return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin) | |
| 218 | + | } | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // newSecret returns "ermite_" and 32 random bytes in base64url. | |
| 223 | + | func newSecret() (string, error) { | |
| 224 | + | var raw [32]byte | |
| 225 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 226 | + | return "", fmt.Errorf("random token secret: %w", err) | |
| 227 | + | } | |
| 228 | + | return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:]), nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // newTokenID returns 16 hex characters. | |
| 232 | + | func newTokenID() (string, error) { | |
| 233 | + | var raw [8]byte | |
| 234 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 235 | + | return "", fmt.Errorf("random token id: %w", err) | |
| 236 | + | } | |
| 237 | + | return hex.EncodeToString(raw[:]), nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Auth authenticates secrets presented by callers. | |
| 241 | + | type Auth struct { | |
| 242 | + | root domain.TokenHash | |
| 243 | + | tokens ports.TokenStore | |
| 244 | + | clock ports.Clock | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // NewAuth builds the authentication use case. rootHash is the SHA-256 of the | |
| 248 | + | // admin token. | |
| 249 | + | func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth { | |
| 250 | + | return &Auth{root: rootHash, tokens: tokens, clock: clock} | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // Authenticate resolves a token secret to its principal: root when it is the | |
| 254 | + | // admin token (constant-time comparison of the hashes), else the stored token | |
| 255 | + | // with that hash if it has not expired. Any other secret, including the empty | |
| 256 | + | // one, gives domain.ErrUnauthenticated. last_used_at of a stored token is | |
| 257 | + | // written at most once a minute; a failure to write it is logged, not | |
| 258 | + | // returned. | |
| 259 | + | func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |
| 260 | + | if secret == "" { | |
| 261 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 262 | + | } | |
| 263 | + | h := domain.HashTokenSecret(secret) | |
| 264 | + | if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 { | |
| 265 | + | return domain.Root(), nil | |
| 266 | + | } | |
| 267 | + | tok, err := a.tokens.FindByHash(ctx, h) | |
| 268 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 269 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 270 | + | } | |
| 271 | + | if err != nil { | |
| 272 | + | return domain.Principal{}, fmt.Errorf("find token: %w", err) | |
| 273 | + | } | |
| 274 | + | now := a.clock.Now().UTC() | |
| 275 | + | if tok.Expired(now) { | |
| 276 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 277 | + | } | |
| 278 | + | if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval { | |
| 279 | + | if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 280 | + | log := zerowrap.FromCtx(ctx) | |
| 281 | + | log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use") | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | return domain.TokenPrincipal(tok), nil | |
| 285 | + | } | |
| @@ -0,0 +1,285 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "crypto/subtle" | |||
| 9 | + | "encoding/base64" | |||
| 10 | + | "encoding/hex" | |||
| 11 | + | "errors" | |||
| 12 | + | "fmt" | |||
| 13 | + | "slices" | |||
| 14 | + | "time" | |||
| 15 | + | "unicode/utf8" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // MaxTokenLabelLen is the longest token label, in bytes. | |||
| 24 | + | const MaxTokenLabelLen = 100 | |||
| 25 | + | ||||
| 26 | + | // touchInterval is how often last_used_at of a token is written at most. | |||
| 27 | + | const touchInterval = time.Minute | |||
| 28 | + | ||||
| 29 | + | // Tokens are the use cases that manage stored tokens. | |||
| 30 | + | type Tokens struct { | |||
| 31 | + | store ports.TokenStore | |||
| 32 | + | clock ports.Clock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // NewTokens builds the token use cases over their ports. | |||
| 36 | + | func NewTokens(store ports.TokenStore, clock ports.Clock) *Tokens { | |||
| 37 | + | return &Tokens{store: store, clock: clock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // CreateTokenParams are the settings of a new token. | |||
| 41 | + | type CreateTokenParams struct { | |||
| 42 | + | Label string | |||
| 43 | + | // Scopes must not be empty. | |||
| 44 | + | Scopes []domain.Scope | |||
| 45 | + | // Repos limits the token to these repositories; empty means every one. | |||
| 46 | + | Repos []domain.RepoName | |||
| 47 | + | // ExpiresAt is the zero time for a token that never expires. | |||
| 48 | + | ExpiresAt time.Time | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // CreatedToken is a new token with its secret, which is shown once and never | |||
| 52 | + | // stored. | |||
| 53 | + | type CreatedToken struct { | |||
| 54 | + | Token domain.Token | |||
| 55 | + | Secret string | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Create makes a stored token and returns it with its secret. | |||
| 59 | + | // | |||
| 60 | + | // The principal needs token:admin: anonymous callers get | |||
| 61 | + | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | |||
| 62 | + | // cannot create a token more powerful than itself: every scope must be one it | |||
| 63 | + | // holds, the repositories must lie within its list (a limited token cannot | |||
| 64 | + | // create an unlimited one) and the expiry must not outlast its own. Violations | |||
| 65 | + | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | |||
| 66 | + | // domain.ErrInvalidToken, an unknown scope domain.ErrInvalidScope. | |||
| 67 | + | func (t *Tokens) Create(ctx context.Context, p domain.Principal, params CreateTokenParams) (CreatedToken, error) { | |||
| 68 | + | if p.IsAnonymous() { | |||
| 69 | + | return CreatedToken{}, domain.ErrUnauthenticated | |||
| 70 | + | } | |||
| 71 | + | if !canManageTokens(p) { | |||
| 72 | + | return CreatedToken{}, fmt.Errorf("%w: creating tokens needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin) | |||
| 73 | + | } | |||
| 74 | + | now := t.clock.Now().UTC() | |||
| 75 | + | tok, err := buildToken(params, now) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return CreatedToken{}, err | |||
| 78 | + | } | |||
| 79 | + | if err := checkNoEscalation(p, tok); err != nil { | |||
| 80 | + | return CreatedToken{}, err | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | for range 3 { | |||
| 84 | + | secret, err := newSecret() | |||
| 85 | + | if err != nil { | |||
| 86 | + | return CreatedToken{}, err | |||
| 87 | + | } | |||
| 88 | + | id, err := newTokenID() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return CreatedToken{}, err | |||
| 91 | + | } | |||
| 92 | + | tok.ID, tok.Hash = id, domain.HashTokenSecret(secret) | |||
| 93 | + | err = t.store.Create(ctx, tok) | |||
| 94 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |||
| 95 | + | continue // a random id or secret collided: practically never | |||
| 96 | + | } | |||
| 97 | + | if err != nil { | |||
| 98 | + | return CreatedToken{}, fmt.Errorf("store token: %w", err) | |||
| 99 | + | } | |||
| 100 | + | log := zerowrap.FromCtx(ctx) | |||
| 101 | + | log.Info(). | |||
| 102 | + | Str("token_id", tok.ID).Str("label", tok.Label). | |||
| 103 | + | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)). | |||
| 104 | + | Str("created_by", p.Kind().String()).Str("created_by_token", p.TokenID()). | |||
| 105 | + | Msg("token created") | |||
| 106 | + | return CreatedToken{Token: tok, Secret: secret}, nil | |||
| 107 | + | } | |||
| 108 | + | return CreatedToken{}, errors.New("store token: could not find a free id") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // buildToken validates the settings and returns the token without id and hash. | |||
| 112 | + | func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) { | |||
| 113 | + | if params.Label == "" || len(params.Label) > MaxTokenLabelLen || !utf8.ValidString(params.Label) { | |||
| 114 | + | return domain.Token{}, fmt.Errorf("%w: label must be 1 to %d bytes of UTF-8", domain.ErrInvalidToken, MaxTokenLabelLen) | |||
| 115 | + | } | |||
| 116 | + | if len(params.Scopes) == 0 { | |||
| 117 | + | return domain.Token{}, fmt.Errorf("%w: at least one scope is required", domain.ErrInvalidToken) | |||
| 118 | + | } | |||
| 119 | + | for _, s := range params.Scopes { | |||
| 120 | + | if _, err := domain.ParseScope(s.String()); err != nil { | |||
| 121 | + | return domain.Token{}, err | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | for _, r := range params.Repos { | |||
| 125 | + | if r.IsZero() { | |||
| 126 | + | return domain.Token{}, fmt.Errorf("%w: empty repository name", domain.ErrInvalidToken) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | |||
| 130 | + | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | |||
| 131 | + | } | |||
| 132 | + | tok := domain.Token{ | |||
| 133 | + | Label: params.Label, | |||
| 134 | + | CreatedAt: now, | |||
| 135 | + | ExpiresAt: params.ExpiresAt.UTC(), | |||
| 136 | + | } | |||
| 137 | + | // Canonical order, no duplicates. | |||
| 138 | + | for _, s := range domain.AllScopes() { | |||
| 139 | + | if slices.Contains(params.Scopes, s) { | |||
| 140 | + | tok.Scopes = append(tok.Scopes, s) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | for _, r := range params.Repos { | |||
| 144 | + | if !slices.Contains(tok.Repos, r) { | |||
| 145 | + | tok.Repos = append(tok.Repos, r) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | slices.SortFunc(tok.Repos, func(a, b domain.RepoName) int { return compareNames(a, b) }) | |||
| 149 | + | return tok, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func compareNames(a, b domain.RepoName) int { | |||
| 153 | + | switch { | |||
| 154 | + | case a.String() < b.String(): | |||
| 155 | + | return -1 | |||
| 156 | + | case a.String() > b.String(): | |||
| 157 | + | return 1 | |||
| 158 | + | } | |||
| 159 | + | return 0 | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // checkNoEscalation enforces that tok is within the rights of its creator. | |||
| 163 | + | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 164 | + | if p.IsRoot() { | |||
| 165 | + | return nil | |||
| 166 | + | } | |||
| 167 | + | for _, s := range tok.Scopes { | |||
| 168 | + | if !p.HasScope(s) { | |||
| 169 | + | return fmt.Errorf("%w: scope %s exceeds the creating token", domain.ErrForbidden, s) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if own := p.Repos(); len(own) > 0 { | |||
| 173 | + | if len(tok.Repos) == 0 { | |||
| 174 | + | return fmt.Errorf("%w: the creating token is limited to a repository list, so the new token must be too", domain.ErrForbidden) | |||
| 175 | + | } | |||
| 176 | + | for _, r := range tok.Repos { | |||
| 177 | + | if !slices.Contains(own, r) { | |||
| 178 | + | return fmt.Errorf("%w: repository %s is outside the creating token", domain.ErrForbidden, r) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | if exp := p.ExpiresAt(); !exp.IsZero() { | |||
| 183 | + | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | |||
| 184 | + | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | return nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // List returns the stored tokens, oldest first. It needs token:admin. | |||
| 191 | + | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | |||
| 192 | + | if err := requireTokenAdmin(p); err != nil { | |||
| 193 | + | return nil, err | |||
| 194 | + | } | |||
| 195 | + | return t.store.List(ctx) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // Revoke deletes a stored token; it stops working at once. It needs | |||
| 199 | + | // token:admin and returns domain.ErrNotFound for an unknown id. | |||
| 200 | + | func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error { | |||
| 201 | + | if err := requireTokenAdmin(p); err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | if err := t.store.Revoke(ctx, id); err != nil { | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | log := zerowrap.FromCtx(ctx) | |||
| 208 | + | log.Info().Str("token_id", id).Str("revoked_by", p.Kind().String()).Str("revoked_by_token", p.TokenID()).Msg("token revoked") | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func requireTokenAdmin(p domain.Principal) error { | |||
| 213 | + | if p.IsAnonymous() { | |||
| 214 | + | return domain.ErrUnauthenticated | |||
| 215 | + | } | |||
| 216 | + | if !canManageTokens(p) { | |||
| 217 | + | return fmt.Errorf("%w: needs %s", domain.ErrForbidden, domain.ScopeTokenAdmin) | |||
| 218 | + | } | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // newSecret returns "ermite_" and 32 random bytes in base64url. | |||
| 223 | + | func newSecret() (string, error) { | |||
| 224 | + | var raw [32]byte | |||
| 225 | + | if _, err := rand.Read(raw[:]); err != nil { | |||
| 226 | + | return "", fmt.Errorf("random token secret: %w", err) | |||
| 227 | + | } | |||
| 228 | + | return domain.TokenSecretPrefix + base64.RawURLEncoding.EncodeToString(raw[:]), nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // newTokenID returns 16 hex characters. | |||
| 232 | + | func newTokenID() (string, error) { | |||
| 233 | + | var raw [8]byte | |||
| 234 | + | if _, err := rand.Read(raw[:]); err != nil { | |||
| 235 | + | return "", fmt.Errorf("random token id: %w", err) | |||
| 236 | + | } | |||
| 237 | + | return hex.EncodeToString(raw[:]), nil | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // Auth authenticates secrets presented by callers. | |||
| 241 | + | type Auth struct { | |||
| 242 | + | root domain.TokenHash | |||
| 243 | + | tokens ports.TokenStore | |||
| 244 | + | clock ports.Clock | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // NewAuth builds the authentication use case. rootHash is the SHA-256 of the | |||
| 248 | + | // admin token. | |||
| 249 | + | func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clock) *Auth { | |||
| 250 | + | return &Auth{root: rootHash, tokens: tokens, clock: clock} | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // Authenticate resolves a token secret to its principal: root when it is the | |||
| 254 | + | // admin token (constant-time comparison of the hashes), else the stored token | |||
| 255 | + | // with that hash if it has not expired. Any other secret, including the empty | |||
| 256 | + | // one, gives domain.ErrUnauthenticated. last_used_at of a stored token is | |||
| 257 | + | // written at most once a minute; a failure to write it is logged, not | |||
| 258 | + | // returned. | |||
| 259 | + | func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |||
| 260 | + | if secret == "" { | |||
| 261 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 262 | + | } | |||
| 263 | + | h := domain.HashTokenSecret(secret) | |||
| 264 | + | if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 { | |||
| 265 | + | return domain.Root(), nil | |||
| 266 | + | } | |||
| 267 | + | tok, err := a.tokens.FindByHash(ctx, h) | |||
| 268 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 269 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 270 | + | } | |||
| 271 | + | if err != nil { | |||
| 272 | + | return domain.Principal{}, fmt.Errorf("find token: %w", err) | |||
| 273 | + | } | |||
| 274 | + | now := a.clock.Now().UTC() | |||
| 275 | + | if tok.Expired(now) { | |||
| 276 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 277 | + | } | |||
| 278 | + | if tok.LastUsedAt.IsZero() || now.Sub(tok.LastUsedAt) >= touchInterval { | |||
| 279 | + | if err := a.tokens.Touch(ctx, tok.ID, now); err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 280 | + | log := zerowrap.FromCtx(ctx) | |||
| 281 | + | log.Warn().Err(err).Str("token_id", tok.ID).Msg("record token use") | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | return domain.TokenPrincipal(tok), nil | |||
| 285 | + | } | |||
A internal/app/tokens_test.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 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 | + | ||
| 22 | + | type tokenFixture struct { | |
| 23 | + | store *mocks.TokenStore | |
| 24 | + | clock *mocks.Clock | |
| 25 | + | tokens *app.Tokens | |
| 26 | + | auth *app.Auth | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var rootSecret = "root-secret-0123456789abcdef0123456789" | |
| 30 | + | ||
| 31 | + | func 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 | + | ||
| 40 | + | func scopes(s ...domain.Scope) []domain.Scope { return s } | |
| 41 | + | ||
| 42 | + | func 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 | + | ||
| 51 | + | func 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 | + | ||
| 132 | + | func 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 | + | ||
| 197 | + | func TestTokensListRevoke(t *testing.T) { | |
| 198 | + | ctx := context.Background() | |
| 199 | + | tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)}) | |
| 200 | + | writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 201 | + | ||
| 202 | + | t.Run("list", func(t *testing.T) { | |
| 203 | + | f := newTokenFixture(t) | |
| 204 | + | want := []domain.Token{{ID: "a"}} | |
| 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().Revoke(ctx, "a").Return(nil).Once() | |
| 220 | + | f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once() | |
| 221 | + | require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a")) | |
| 222 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound) | |
| 223 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden) | |
| 224 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated) | |
| 225 | + | }) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestAuthenticate(t *testing.T) { | |
| 229 | + | ctx := context.Background() | |
| 230 | + | secret := "ermite_stored-secret" | |
| 231 | + | hash := domain.HashTokenSecret(secret) | |
| 232 | + | tok := domain.Token{ | |
| 233 | + | ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"), | |
| 234 | + | ExpiresAt: now.Add(time.Hour), | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | t.Run("root", func(t *testing.T) { | |
| 238 | + | f := newTokenFixture(t) | |
| 239 | + | p, err := f.auth.Authenticate(ctx, rootSecret) | |
| 240 | + | require.NoError(t, err) | |
| 241 | + | assert.True(t, p.IsRoot()) | |
| 242 | + | }) | |
| 243 | + | ||
| 244 | + | t.Run("empty and unknown secrets", func(t *testing.T) { | |
| 245 | + | f := newTokenFixture(t) | |
| 246 | + | _, err := f.auth.Authenticate(ctx, "") | |
| 247 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 248 | + | f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound) | |
| 249 | + | _, err = f.auth.Authenticate(ctx, "nope") | |
| 250 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 251 | + | }) | |
| 252 | + | ||
| 253 | + | t.Run("stored token first use touches", func(t *testing.T) { | |
| 254 | + | f := newTokenFixture(t) | |
| 255 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil) | |
| 256 | + | f.store.EXPECT().Touch(ctx, "t1", now).Return(nil) | |
| 257 | + | p, err := f.auth.Authenticate(ctx, secret) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | assert.Equal(t, domain.PrincipalToken, p.Kind()) | |
| 260 | + | assert.Equal(t, "t1", p.TokenID()) | |
| 261 | + | assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a"))) | |
| 262 | + | assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b"))) | |
| 263 | + | }) | |
| 264 | + | ||
| 265 | + | t.Run("touch at most once a minute", func(t *testing.T) { | |
| 266 | + | f := newTokenFixture(t) | |
| 267 | + | recent := tok | |
| 268 | + | recent.LastUsedAt = now.Add(-30 * time.Second) | |
| 269 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil) | |
| 270 | + | _, err := f.auth.Authenticate(ctx, secret) // no Touch expected | |
| 271 | + | require.NoError(t, err) | |
| 272 | + | ||
| 273 | + | f2 := newTokenFixture(t) | |
| 274 | + | old := tok | |
| 275 | + | old.LastUsedAt = now.Add(-time.Minute) | |
| 276 | + | f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil) | |
| 277 | + | f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil) | |
| 278 | + | _, err = f2.auth.Authenticate(ctx, secret) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | }) | |
| 281 | + | ||
| 282 | + | t.Run("touch failure does not fail authentication", func(t *testing.T) { | |
| 283 | + | f := newTokenFixture(t) | |
| 284 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil) | |
| 285 | + | f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk")) | |
| 286 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 287 | + | require.NoError(t, err) | |
| 288 | + | }) | |
| 289 | + | ||
| 290 | + | t.Run("expired", func(t *testing.T) { | |
| 291 | + | f := newTokenFixture(t) | |
| 292 | + | exp := tok | |
| 293 | + | exp.ExpiresAt = now | |
| 294 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil) | |
| 295 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 296 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 297 | + | }) | |
| 298 | + | ||
| 299 | + | t.Run("store error is not an authentication failure", func(t *testing.T) { | |
| 300 | + | f := newTokenFixture(t) | |
| 301 | + | boom := errors.New("boom") | |
| 302 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom) | |
| 303 | + | _, err := f.auth.Authenticate(ctx, secret) | |
| 304 | + | assert.ErrorIs(t, err, boom) | |
| 305 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 306 | + | }) | |
| 307 | + | } | |
| @@ -0,0 +1,307 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 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 | + | ||||
| 22 | + | type tokenFixture struct { | |||
| 23 | + | store *mocks.TokenStore | |||
| 24 | + | clock *mocks.Clock | |||
| 25 | + | tokens *app.Tokens | |||
| 26 | + | auth *app.Auth | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | var rootSecret = "root-secret-0123456789abcdef0123456789" | |||
| 30 | + | ||||
| 31 | + | func 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 | + | ||||
| 40 | + | func scopes(s ...domain.Scope) []domain.Scope { return s } | |||
| 41 | + | ||||
| 42 | + | func 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 | + | ||||
| 51 | + | func 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 | + | ||||
| 132 | + | func 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 | + | ||||
| 197 | + | func TestTokensListRevoke(t *testing.T) { | |||
| 198 | + | ctx := context.Background() | |||
| 199 | + | tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)}) | |||
| 200 | + | writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}) | |||
| 201 | + | ||||
| 202 | + | t.Run("list", func(t *testing.T) { | |||
| 203 | + | f := newTokenFixture(t) | |||
| 204 | + | want := []domain.Token{{ID: "a"}} | |||
| 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().Revoke(ctx, "a").Return(nil).Once() | |||
| 220 | + | f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once() | |||
| 221 | + | require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a")) | |||
| 222 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound) | |||
| 223 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden) | |||
| 224 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated) | |||
| 225 | + | }) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestAuthenticate(t *testing.T) { | |||
| 229 | + | ctx := context.Background() | |||
| 230 | + | secret := "ermite_stored-secret" | |||
| 231 | + | hash := domain.HashTokenSecret(secret) | |||
| 232 | + | tok := domain.Token{ | |||
| 233 | + | ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"), | |||
| 234 | + | ExpiresAt: now.Add(time.Hour), | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | t.Run("root", func(t *testing.T) { | |||
| 238 | + | f := newTokenFixture(t) | |||
| 239 | + | p, err := f.auth.Authenticate(ctx, rootSecret) | |||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | assert.True(t, p.IsRoot()) | |||
| 242 | + | }) | |||
| 243 | + | ||||
| 244 | + | t.Run("empty and unknown secrets", func(t *testing.T) { | |||
| 245 | + | f := newTokenFixture(t) | |||
| 246 | + | _, err := f.auth.Authenticate(ctx, "") | |||
| 247 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 248 | + | f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound) | |||
| 249 | + | _, err = f.auth.Authenticate(ctx, "nope") | |||
| 250 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 251 | + | }) | |||
| 252 | + | ||||
| 253 | + | t.Run("stored token first use touches", func(t *testing.T) { | |||
| 254 | + | f := newTokenFixture(t) | |||
| 255 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil) | |||
| 256 | + | f.store.EXPECT().Touch(ctx, "t1", now).Return(nil) | |||
| 257 | + | p, err := f.auth.Authenticate(ctx, secret) | |||
| 258 | + | require.NoError(t, err) | |||
| 259 | + | assert.Equal(t, domain.PrincipalToken, p.Kind()) | |||
| 260 | + | assert.Equal(t, "t1", p.TokenID()) | |||
| 261 | + | assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a"))) | |||
| 262 | + | assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b"))) | |||
| 263 | + | }) | |||
| 264 | + | ||||
| 265 | + | t.Run("touch at most once a minute", func(t *testing.T) { | |||
| 266 | + | f := newTokenFixture(t) | |||
| 267 | + | recent := tok | |||
| 268 | + | recent.LastUsedAt = now.Add(-30 * time.Second) | |||
| 269 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil) | |||
| 270 | + | _, err := f.auth.Authenticate(ctx, secret) // no Touch expected | |||
| 271 | + | require.NoError(t, err) | |||
| 272 | + | ||||
| 273 | + | f2 := newTokenFixture(t) | |||
| 274 | + | old := tok | |||
| 275 | + | old.LastUsedAt = now.Add(-time.Minute) | |||
| 276 | + | f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil) | |||
| 277 | + | f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil) | |||
| 278 | + | _, err = f2.auth.Authenticate(ctx, secret) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | }) | |||
| 281 | + | ||||
| 282 | + | t.Run("touch failure does not fail authentication", func(t *testing.T) { | |||
| 283 | + | f := newTokenFixture(t) | |||
| 284 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil) | |||
| 285 | + | f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk")) | |||
| 286 | + | _, err := f.auth.Authenticate(ctx, secret) | |||
| 287 | + | require.NoError(t, err) | |||
| 288 | + | }) | |||
| 289 | + | ||||
| 290 | + | t.Run("expired", func(t *testing.T) { | |||
| 291 | + | f := newTokenFixture(t) | |||
| 292 | + | exp := tok | |||
| 293 | + | exp.ExpiresAt = now | |||
| 294 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil) | |||
| 295 | + | _, err := f.auth.Authenticate(ctx, secret) | |||
| 296 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 297 | + | }) | |||
| 298 | + | ||||
| 299 | + | t.Run("store error is not an authentication failure", func(t *testing.T) { | |||
| 300 | + | f := newTokenFixture(t) | |||
| 301 | + | boom := errors.New("boom") | |||
| 302 | + | f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom) | |||
| 303 | + | _, err := f.auth.Authenticate(ctx, secret) | |||
| 304 | + | assert.ErrorIs(t, err, boom) | |||
| 305 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 306 | + | }) | |||
| 307 | + | } | |||