feat(session): add the owner principal, session store and login use cases
The owner signed in on the web is a principal with the rights of root, labelled owner. Sessions hold the HS256 key and epoch in ermite-kv, the login links in memory (60 s, single use, bounded), and sessions are JWTs checked with a pinned algorithm and the current epoch.
- commit
- c1f3cc9f9544744820e60ed3734bae7a6ef9d2cb
- author
- brice · · 2026-10-10 17:05 UTC
- committer
- brice · · 2026-10-10 17:05 UTC
- parent
- 1811509
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +1223 -7
M .mockery.yml +1 -0
| @@ -12,6 +12,7 @@ packages: | |||
| 12 | 12 | GitRepos: | |
| 13 | 13 | Clock: | |
| 14 | 14 | TokenStore: | |
| 15 | + | SessionStore: | |
| 15 | 16 | GitBackend: | |
| 16 | 17 | EventBus: | |
| 17 | 18 | RepoReader: | |
| @@ -12,6 +12,7 @@ packages: | |||||
| 12 | GitRepos: | 12 | GitRepos: | ||
| 13 | Clock: | 13 | Clock: | ||
| 14 | TokenStore: | 14 | TokenStore: | ||
| 15 | + | SessionStore: | |||
| 15 | GitBackend: | 16 | GitBackend: | ||
| 16 | EventBus: | 17 | EventBus: | ||
| 17 | RepoReader: | 18 | RepoReader: | ||
A gen/ermite/storage/session.pb.go +181 -0 generated file: collapsed
M go.mod +1 -0
| @@ -18,6 +18,7 @@ require ( | |||
| 18 | 18 | github.com/a-h/templ v0.3.1020 | |
| 19 | 19 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 20 | 20 | github.com/bnema/zerowrap v1.4.1 | |
| 21 | + | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 21 | 22 | github.com/spf13/cobra v1.10.2 | |
| 22 | 23 | github.com/stretchr/testify v1.12.1 | |
| 23 | 24 | github.com/yuin/goldmark v1.8.6 | |
| @@ -18,6 +18,7 @@ require ( | |||||
| 18 | github.com/a-h/templ v0.3.1020 | 18 | github.com/a-h/templ v0.3.1020 | ||
| 19 | github.com/alecthomas/chroma/v2 v2.27.0 | 19 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 20 | github.com/bnema/zerowrap v1.4.1 | 20 | github.com/bnema/zerowrap v1.4.1 | ||
| 21 | + | github.com/golang-jwt/jwt/v5 v5.3.1 | |||
| 21 | github.com/spf13/cobra v1.10.2 | 22 | github.com/spf13/cobra v1.10.2 | ||
| 22 | github.com/stretchr/testify v1.12.1 | 23 | github.com/stretchr/testify v1.12.1 | ||
| 23 | github.com/yuin/goldmark v1.8.6 | 24 | github.com/yuin/goldmark v1.8.6 | ||
M go.sum +2 -0 generated file: collapsed
A internal/adapters/kvstore/sessions.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | ||
| 11 | + | kv "bnema.dev/ermite-kv" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | sessionKeyKey = "session/key" | |
| 22 | + | sessionEpochKey = "session/epoch" | |
| 23 | + | ||
| 24 | + | // sessionKeyLen is the size of the HS256 secret, in bytes. | |
| 25 | + | sessionKeyLen = 32 | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // Sessions is the ermite-kv backed ports.SessionStore. It shares the database | |
| 29 | + | // of the Store it comes from. | |
| 30 | + | type Sessions struct { | |
| 31 | + | db *kv.DB | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | var _ ports.SessionStore = (*Sessions)(nil) | |
| 35 | + | ||
| 36 | + | // Sessions returns the session store over the same database. It is valid | |
| 37 | + | // until the Store is closed. | |
| 38 | + | func (s *Store) Sessions() *Sessions { return &Sessions{db: s.db} } | |
| 39 | + | ||
| 40 | + | // Key reads session/key and generates it when absent. The key is never | |
| 41 | + | // logged. | |
| 42 | + | func (s *Sessions) Key(ctx context.Context) ([]byte, error) { | |
| 43 | + | for range updateAttempts { | |
| 44 | + | if item, ok := s.db.Get(ctx, []byte(sessionKeyKey)); ok { | |
| 45 | + | var m storagepb.SessionKey | |
| 46 | + | if err := proto.Unmarshal(item.Value, &m); err != nil { | |
| 47 | + | return nil, fmt.Errorf("decode session key: %w", err) | |
| 48 | + | } | |
| 49 | + | if len(m.GetKey()) != sessionKeyLen { | |
| 50 | + | return nil, fmt.Errorf("decode session key: %d bytes, want %d", len(m.GetKey()), sessionKeyLen) | |
| 51 | + | } | |
| 52 | + | return append([]byte(nil), m.GetKey()...), nil | |
| 53 | + | } | |
| 54 | + | key := make([]byte, sessionKeyLen) | |
| 55 | + | if _, err := rand.Read(key); err != nil { | |
| 56 | + | return nil, fmt.Errorf("generate session key: %w", err) | |
| 57 | + | } | |
| 58 | + | var b kv.Batch | |
| 59 | + | b.RequireAbsent([]byte(sessionKeyKey)) | |
| 60 | + | b.Put([]byte(sessionKeyKey), mustMarshal(&storagepb.SessionKey{Key: key})) | |
| 61 | + | _, err := s.db.Apply(ctx, &b) | |
| 62 | + | if errors.Is(err, kv.ErrConflict) { | |
| 63 | + | continue // written meanwhile: read it | |
| 64 | + | } | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, fmt.Errorf("store session key: %w", err) | |
| 67 | + | } | |
| 68 | + | log := zerowrap.FromCtx(ctx) | |
| 69 | + | log.Info().Msg("session key generated") | |
| 70 | + | return key, nil | |
| 71 | + | } | |
| 72 | + | return nil, fmt.Errorf("store session key: %w", domain.ErrConflict) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Epoch reads session/epoch; a missing key is epoch 0. | |
| 76 | + | func (s *Sessions) Epoch(ctx context.Context) (uint64, error) { | |
| 77 | + | e, _, err := s.readEpoch(ctx) | |
| 78 | + | return e, err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (s *Sessions) readEpoch(ctx context.Context) (epoch, version uint64, err error) { | |
| 82 | + | item, ok := s.db.Get(ctx, []byte(sessionEpochKey)) | |
| 83 | + | if !ok { | |
| 84 | + | return 0, 0, nil | |
| 85 | + | } | |
| 86 | + | var m storagepb.SessionEpoch | |
| 87 | + | if err := proto.Unmarshal(item.Value, &m); err != nil { | |
| 88 | + | return 0, 0, fmt.Errorf("decode session epoch: %w", err) | |
| 89 | + | } | |
| 90 | + | return m.GetEpoch(), item.Version, nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // BumpEpoch increments session/epoch with a version check, so that two | |
| 94 | + | // concurrent bumps give two different values. | |
| 95 | + | func (s *Sessions) BumpEpoch(ctx context.Context) (uint64, error) { | |
| 96 | + | for range updateAttempts { | |
| 97 | + | cur, version, err := s.readEpoch(ctx) | |
| 98 | + | if err != nil { | |
| 99 | + | return 0, err | |
| 100 | + | } | |
| 101 | + | var b kv.Batch | |
| 102 | + | if version == 0 { | |
| 103 | + | b.RequireAbsent([]byte(sessionEpochKey)) | |
| 104 | + | } else { | |
| 105 | + | b.RequireVersion([]byte(sessionEpochKey), version) | |
| 106 | + | } | |
| 107 | + | b.Put([]byte(sessionEpochKey), mustMarshal(&storagepb.SessionEpoch{Epoch: cur + 1})) | |
| 108 | + | _, err = s.db.Apply(ctx, &b) | |
| 109 | + | if errors.Is(err, kv.ErrConflict) { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | if err != nil { | |
| 113 | + | return 0, fmt.Errorf("bump session epoch: %w", err) | |
| 114 | + | } | |
| 115 | + | return cur + 1, nil | |
| 116 | + | } | |
| 117 | + | return 0, fmt.Errorf("bump session epoch: %w", domain.ErrConflict) | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | ||||
| 11 | + | kv "bnema.dev/ermite-kv" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "google.golang.org/protobuf/proto" | |||
| 14 | + | ||||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | sessionKeyKey = "session/key" | |||
| 22 | + | sessionEpochKey = "session/epoch" | |||
| 23 | + | ||||
| 24 | + | // sessionKeyLen is the size of the HS256 secret, in bytes. | |||
| 25 | + | sessionKeyLen = 32 | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // Sessions is the ermite-kv backed ports.SessionStore. It shares the database | |||
| 29 | + | // of the Store it comes from. | |||
| 30 | + | type Sessions struct { | |||
| 31 | + | db *kv.DB | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | var _ ports.SessionStore = (*Sessions)(nil) | |||
| 35 | + | ||||
| 36 | + | // Sessions returns the session store over the same database. It is valid | |||
| 37 | + | // until the Store is closed. | |||
| 38 | + | func (s *Store) Sessions() *Sessions { return &Sessions{db: s.db} } | |||
| 39 | + | ||||
| 40 | + | // Key reads session/key and generates it when absent. The key is never | |||
| 41 | + | // logged. | |||
| 42 | + | func (s *Sessions) Key(ctx context.Context) ([]byte, error) { | |||
| 43 | + | for range updateAttempts { | |||
| 44 | + | if item, ok := s.db.Get(ctx, []byte(sessionKeyKey)); ok { | |||
| 45 | + | var m storagepb.SessionKey | |||
| 46 | + | if err := proto.Unmarshal(item.Value, &m); err != nil { | |||
| 47 | + | return nil, fmt.Errorf("decode session key: %w", err) | |||
| 48 | + | } | |||
| 49 | + | if len(m.GetKey()) != sessionKeyLen { | |||
| 50 | + | return nil, fmt.Errorf("decode session key: %d bytes, want %d", len(m.GetKey()), sessionKeyLen) | |||
| 51 | + | } | |||
| 52 | + | return append([]byte(nil), m.GetKey()...), nil | |||
| 53 | + | } | |||
| 54 | + | key := make([]byte, sessionKeyLen) | |||
| 55 | + | if _, err := rand.Read(key); err != nil { | |||
| 56 | + | return nil, fmt.Errorf("generate session key: %w", err) | |||
| 57 | + | } | |||
| 58 | + | var b kv.Batch | |||
| 59 | + | b.RequireAbsent([]byte(sessionKeyKey)) | |||
| 60 | + | b.Put([]byte(sessionKeyKey), mustMarshal(&storagepb.SessionKey{Key: key})) | |||
| 61 | + | _, err := s.db.Apply(ctx, &b) | |||
| 62 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 63 | + | continue // written meanwhile: read it | |||
| 64 | + | } | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, fmt.Errorf("store session key: %w", err) | |||
| 67 | + | } | |||
| 68 | + | log := zerowrap.FromCtx(ctx) | |||
| 69 | + | log.Info().Msg("session key generated") | |||
| 70 | + | return key, nil | |||
| 71 | + | } | |||
| 72 | + | return nil, fmt.Errorf("store session key: %w", domain.ErrConflict) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Epoch reads session/epoch; a missing key is epoch 0. | |||
| 76 | + | func (s *Sessions) Epoch(ctx context.Context) (uint64, error) { | |||
| 77 | + | e, _, err := s.readEpoch(ctx) | |||
| 78 | + | return e, err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (s *Sessions) readEpoch(ctx context.Context) (epoch, version uint64, err error) { | |||
| 82 | + | item, ok := s.db.Get(ctx, []byte(sessionEpochKey)) | |||
| 83 | + | if !ok { | |||
| 84 | + | return 0, 0, nil | |||
| 85 | + | } | |||
| 86 | + | var m storagepb.SessionEpoch | |||
| 87 | + | if err := proto.Unmarshal(item.Value, &m); err != nil { | |||
| 88 | + | return 0, 0, fmt.Errorf("decode session epoch: %w", err) | |||
| 89 | + | } | |||
| 90 | + | return m.GetEpoch(), item.Version, nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // BumpEpoch increments session/epoch with a version check, so that two | |||
| 94 | + | // concurrent bumps give two different values. | |||
| 95 | + | func (s *Sessions) BumpEpoch(ctx context.Context) (uint64, error) { | |||
| 96 | + | for range updateAttempts { | |||
| 97 | + | cur, version, err := s.readEpoch(ctx) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return 0, err | |||
| 100 | + | } | |||
| 101 | + | var b kv.Batch | |||
| 102 | + | if version == 0 { | |||
| 103 | + | b.RequireAbsent([]byte(sessionEpochKey)) | |||
| 104 | + | } else { | |||
| 105 | + | b.RequireVersion([]byte(sessionEpochKey), version) | |||
| 106 | + | } | |||
| 107 | + | b.Put([]byte(sessionEpochKey), mustMarshal(&storagepb.SessionEpoch{Epoch: cur + 1})) | |||
| 108 | + | _, err = s.db.Apply(ctx, &b) | |||
| 109 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | if err != nil { | |||
| 113 | + | return 0, fmt.Errorf("bump session epoch: %w", err) | |||
| 114 | + | } | |||
| 115 | + | return cur + 1, nil | |||
| 116 | + | } | |||
| 117 | + | return 0, fmt.Errorf("bump session epoch: %w", domain.ErrConflict) | |||
| 118 | + | } | |||
A internal/adapters/kvstore/sessions_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // The key is generated once and survives a restart; the epoch counts up and | |
| 15 | + | // survives it too. | |
| 16 | + | func TestSessionsPersist(t *testing.T) { | |
| 17 | + | ctx := context.Background() | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | s := openStore(t, dir) | |
| 20 | + | ||
| 21 | + | key, err := s.Sessions().Key(ctx) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | assert.Len(t, key, 32) | |
| 24 | + | again, err := s.Sessions().Key(ctx) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | assert.Equal(t, key, again) | |
| 27 | + | ||
| 28 | + | e, err := s.Sessions().Epoch(ctx) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | assert.Zero(t, e) | |
| 31 | + | for want := uint64(1); want <= 3; want++ { | |
| 32 | + | got, err := s.Sessions().BumpEpoch(ctx) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | assert.Equal(t, want, got) | |
| 35 | + | } | |
| 36 | + | require.NoError(t, s.Close(ctx)) | |
| 37 | + | ||
| 38 | + | s = openStore(t, dir) | |
| 39 | + | reopened, err := s.Sessions().Key(ctx) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.Equal(t, key, reopened, "a restart keeps the key") | |
| 42 | + | e, err = s.Sessions().Epoch(ctx) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, uint64(3), e) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Concurrent bumps never give the same epoch twice, and concurrent first | |
| 48 | + | // reads agree on one key. | |
| 49 | + | func TestSessionsConcurrent(t *testing.T) { | |
| 50 | + | ctx := context.Background() | |
| 51 | + | s := openStore(t, t.TempDir()) | |
| 52 | + | var wg sync.WaitGroup | |
| 53 | + | keys := make([][]byte, 8) | |
| 54 | + | epochs := make(chan uint64, 8) | |
| 55 | + | for i := range 8 { | |
| 56 | + | wg.Add(1) | |
| 57 | + | go func() { | |
| 58 | + | defer wg.Done() | |
| 59 | + | k, err := s.Sessions().Key(ctx) | |
| 60 | + | assert.NoError(t, err) | |
| 61 | + | keys[i] = k | |
| 62 | + | if e, err := s.Sessions().BumpEpoch(ctx); err == nil { | |
| 63 | + | epochs <- e | |
| 64 | + | } | |
| 65 | + | }() | |
| 66 | + | } | |
| 67 | + | wg.Wait() | |
| 68 | + | close(epochs) | |
| 69 | + | for _, k := range keys[1:] { | |
| 70 | + | assert.Equal(t, keys[0], k) | |
| 71 | + | } | |
| 72 | + | seen := map[uint64]bool{} | |
| 73 | + | for e := range epochs { | |
| 74 | + | assert.False(t, seen[e], "epoch %d twice", e) | |
| 75 | + | seen[e] = true | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // The key is generated once and survives a restart; the epoch counts up and | |||
| 15 | + | // survives it too. | |||
| 16 | + | func TestSessionsPersist(t *testing.T) { | |||
| 17 | + | ctx := context.Background() | |||
| 18 | + | dir := t.TempDir() | |||
| 19 | + | s := openStore(t, dir) | |||
| 20 | + | ||||
| 21 | + | key, err := s.Sessions().Key(ctx) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | assert.Len(t, key, 32) | |||
| 24 | + | again, err := s.Sessions().Key(ctx) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | assert.Equal(t, key, again) | |||
| 27 | + | ||||
| 28 | + | e, err := s.Sessions().Epoch(ctx) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | assert.Zero(t, e) | |||
| 31 | + | for want := uint64(1); want <= 3; want++ { | |||
| 32 | + | got, err := s.Sessions().BumpEpoch(ctx) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | assert.Equal(t, want, got) | |||
| 35 | + | } | |||
| 36 | + | require.NoError(t, s.Close(ctx)) | |||
| 37 | + | ||||
| 38 | + | s = openStore(t, dir) | |||
| 39 | + | reopened, err := s.Sessions().Key(ctx) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | assert.Equal(t, key, reopened, "a restart keeps the key") | |||
| 42 | + | e, err = s.Sessions().Epoch(ctx) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, uint64(3), e) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Concurrent bumps never give the same epoch twice, and concurrent first | |||
| 48 | + | // reads agree on one key. | |||
| 49 | + | func TestSessionsConcurrent(t *testing.T) { | |||
| 50 | + | ctx := context.Background() | |||
| 51 | + | s := openStore(t, t.TempDir()) | |||
| 52 | + | var wg sync.WaitGroup | |||
| 53 | + | keys := make([][]byte, 8) | |||
| 54 | + | epochs := make(chan uint64, 8) | |||
| 55 | + | for i := range 8 { | |||
| 56 | + | wg.Add(1) | |||
| 57 | + | go func() { | |||
| 58 | + | defer wg.Done() | |||
| 59 | + | k, err := s.Sessions().Key(ctx) | |||
| 60 | + | assert.NoError(t, err) | |||
| 61 | + | keys[i] = k | |||
| 62 | + | if e, err := s.Sessions().BumpEpoch(ctx); err == nil { | |||
| 63 | + | epochs <- e | |||
| 64 | + | } | |||
| 65 | + | }() | |||
| 66 | + | } | |||
| 67 | + | wg.Wait() | |||
| 68 | + | close(epochs) | |||
| 69 | + | for _, k := range keys[1:] { | |||
| 70 | + | assert.Equal(t, keys[0], k) | |||
| 71 | + | } | |||
| 72 | + | seen := map[uint64]bool{} | |||
| 73 | + | for e := range epochs { | |||
| 74 | + | assert.False(t, seen[e], "epoch %d twice", e) | |||
| 75 | + | seen[e] = true | |||
| 76 | + | } | |||
| 77 | + | } | |||
A internal/app/sessions.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | "github.com/golang-jwt/jwt/v5" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Web login (ADR 0018). The owner asks for a one-time link with the API, opens | |
| 23 | + | // it in a browser and confirms; the confirmation trades the link for a signed | |
| 24 | + | // session token that the web keeps in an HttpOnly cookie. | |
| 25 | + | const ( | |
| 26 | + | // LoginLinkTTL is how long a login link works. | |
| 27 | + | LoginLinkTTL = 60 * time.Second | |
| 28 | + | // MaxPendingLoginLinks bounds the links that wait to be used, so that a | |
| 29 | + | // loop of CreateLoginLink cannot grow the memory. | |
| 30 | + | MaxPendingLoginLinks = 16 | |
| 31 | + | // SessionTTL is the lifetime of a session. | |
| 32 | + | SessionTTL = 12 * time.Hour | |
| 33 | + | ||
| 34 | + | // loginNonceBytes is the size of the secret in a link: 256 bits. | |
| 35 | + | loginNonceBytes = 32 | |
| 36 | + | // sessionSubject is the "sub" claim of every session: there is one owner. | |
| 37 | + | sessionSubject = "owner" | |
| 38 | + | // LoginPath is the path prefix of login links on the public URL. | |
| 39 | + | LoginPath = "/-/login/" | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // LoginLink is a login link as the owner receives it. | |
| 43 | + | type LoginLink struct { | |
| 44 | + | // URL holds the secret: show it once, never log it. | |
| 45 | + | URL string | |
| 46 | + | ExpiresAt time.Time | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Sessions are the use cases of the owner's browser sessions: login links, | |
| 50 | + | // session tokens and signing out everywhere. Login links live in memory only, | |
| 51 | + | // so a restart voids them; the signing key and the epoch are in the store. | |
| 52 | + | type Sessions struct { | |
| 53 | + | store ports.SessionStore | |
| 54 | + | clock ports.Clock | |
| 55 | + | publicURL string | |
| 56 | + | key []byte | |
| 57 | + | ||
| 58 | + | mu sync.Mutex | |
| 59 | + | links map[[sha256.Size]byte]time.Time // by hash of the nonce, to the expiry | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // NewSessions builds the session use cases and loads the signing key, which | |
| 63 | + | // the store generates on first start. publicURL is the base URL the login | |
| 64 | + | // links are built on. | |
| 65 | + | func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Clock, publicURL string) (*Sessions, error) { | |
| 66 | + | key, err := store.Key(ctx) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, fmt.Errorf("load session key: %w", err) | |
| 69 | + | } | |
| 70 | + | return &Sessions{store: store, clock: clock, publicURL: publicURL, key: key, links: map[[sha256.Size]byte]time.Time{}}, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // requireOwner is the access rule of the session API: root, or a stored token | |
| 74 | + | // with every scope and no repository list. | |
| 75 | + | func requireOwner(p domain.Principal) error { | |
| 76 | + | switch { | |
| 77 | + | case p.IsAnonymous(): | |
| 78 | + | return domain.ErrUnauthenticated | |
| 79 | + | case !p.HasOwnerRights(): | |
| 80 | + | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden) | |
| 81 | + | } | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // CreateLoginLink makes a link valid for LoginLinkTTL and usable once. Only | |
| 86 | + | // the owner may. It gives domain.ErrResourceExhausted when MaxPendingLoginLinks | |
| 87 | + | // links wait. The nonce is never logged. | |
| 88 | + | func (s *Sessions) CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error) { | |
| 89 | + | if err := requireOwner(p); err != nil { | |
| 90 | + | return LoginLink{}, err | |
| 91 | + | } | |
| 92 | + | var raw [loginNonceBytes]byte | |
| 93 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 94 | + | return LoginLink{}, fmt.Errorf("generate login link: %w", err) | |
| 95 | + | } | |
| 96 | + | nonce := base64.RawURLEncoding.EncodeToString(raw[:]) | |
| 97 | + | now := s.clock.Now() | |
| 98 | + | exp := now.Add(LoginLinkTTL) | |
| 99 | + | ||
| 100 | + | s.mu.Lock() | |
| 101 | + | for h, e := range s.links { | |
| 102 | + | if !now.Before(e) { | |
| 103 | + | delete(s.links, h) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if len(s.links) >= MaxPendingLoginLinks { | |
| 107 | + | s.mu.Unlock() | |
| 108 | + | return LoginLink{}, fmt.Errorf("%w: %d login links are already pending, use one or wait a minute", domain.ErrResourceExhausted, MaxPendingLoginLinks) | |
| 109 | + | } | |
| 110 | + | s.links[sha256.Sum256([]byte(nonce))] = exp | |
| 111 | + | s.mu.Unlock() | |
| 112 | + | ||
| 113 | + | log := zerowrap.FromCtx(ctx) | |
| 114 | + | log.Info().Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).Time("expires_at", exp).Msg("login link created") | |
| 115 | + | return LoginLink{URL: s.publicURL + LoginPath + nonce, ExpiresAt: exp.UTC()}, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // LoginLinkValid reports whether the nonce is a pending link, without using | |
| 119 | + | // it: opening a link shows a confirmation, only confirming consumes it. | |
| 120 | + | func (s *Sessions) LoginLinkValid(nonce string) bool { | |
| 121 | + | h := sha256.Sum256([]byte(nonce)) | |
| 122 | + | s.mu.Lock() | |
| 123 | + | defer s.mu.Unlock() | |
| 124 | + | exp, ok := s.links[h] | |
| 125 | + | return ok && s.clock.Now().Before(exp) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Login consumes the link and opens a session: it returns the signed session | |
| 129 | + | // token, for the cookie, and when it expires. A link that is unknown, expired | |
| 130 | + | // or already used gives domain.ErrUnauthenticated, whatever the reason. The | |
| 131 | + | // nonce and the token are never logged. | |
| 132 | + | func (s *Sessions) Login(ctx context.Context, nonce string) (token string, expiresAt time.Time, err error) { | |
| 133 | + | h := sha256.Sum256([]byte(nonce)) | |
| 134 | + | s.mu.Lock() | |
| 135 | + | exp, ok := s.links[h] | |
| 136 | + | delete(s.links, h) // single use, also when expired | |
| 137 | + | s.mu.Unlock() | |
| 138 | + | if !ok || !s.clock.Now().Before(exp) { | |
| 139 | + | return "", time.Time{}, domain.ErrUnauthenticated | |
| 140 | + | } | |
| 141 | + | token, expiresAt, err = s.issue(ctx) | |
| 142 | + | if err != nil { | |
| 143 | + | return "", time.Time{}, err | |
| 144 | + | } | |
| 145 | + | log := zerowrap.FromCtx(ctx) | |
| 146 | + | log.Info().Time("expires_at", expiresAt).Msg("owner signed in on the web") | |
| 147 | + | return token, expiresAt, nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // sessionClaims are the claims of a session token. The epoch is a pointer so | |
| 151 | + | // that a token without one is not taken for epoch 0. | |
| 152 | + | type sessionClaims struct { | |
| 153 | + | Epoch *uint64 `json:"epoch"` | |
| 154 | + | jwt.RegisteredClaims | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (s *Sessions) issue(ctx context.Context) (string, time.Time, error) { | |
| 158 | + | epoch, err := s.store.Epoch(ctx) | |
| 159 | + | if err != nil { | |
| 160 | + | return "", time.Time{}, fmt.Errorf("read session epoch: %w", err) | |
| 161 | + | } | |
| 162 | + | now := s.clock.Now().UTC() | |
| 163 | + | exp := now.Add(SessionTTL) | |
| 164 | + | tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ | |
| 165 | + | Epoch: &epoch, | |
| 166 | + | RegisteredClaims: jwt.RegisteredClaims{ | |
| 167 | + | Subject: sessionSubject, | |
| 168 | + | IssuedAt: jwt.NewNumericDate(now), | |
| 169 | + | ExpiresAt: jwt.NewNumericDate(exp), | |
| 170 | + | }, | |
| 171 | + | }).SignedString(s.key) | |
| 172 | + | if err != nil { | |
| 173 | + | return "", time.Time{}, fmt.Errorf("sign session: %w", err) | |
| 174 | + | } | |
| 175 | + | return tok, exp.Truncate(time.Second), nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Verify checks a session token and returns the owner. The signature must be | |
| 179 | + | // HS256 with the session key (the algorithm is pinned, so "none" and any other | |
| 180 | + | // are refused), the token must not be expired or issued in the future, and its | |
| 181 | + | // epoch must be the current one. Any such failure gives | |
| 182 | + | // domain.ErrUnauthenticated; a store failure is returned as it is, so that the | |
| 183 | + | // caller fails closed. | |
| 184 | + | func (s *Sessions) Verify(ctx context.Context, token string) (domain.Principal, error) { | |
| 185 | + | var c sessionClaims | |
| 186 | + | _, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return s.key, nil }, | |
| 187 | + | jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), | |
| 188 | + | jwt.WithExpirationRequired(), | |
| 189 | + | jwt.WithIssuedAt(), | |
| 190 | + | jwt.WithSubject(sessionSubject), | |
| 191 | + | jwt.WithTimeFunc(s.clock.Now), | |
| 192 | + | ) | |
| 193 | + | if err != nil || c.Epoch == nil { | |
| 194 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 195 | + | } | |
| 196 | + | epoch, err := s.store.Epoch(ctx) | |
| 197 | + | if err != nil { | |
| 198 | + | return domain.Principal{}, fmt.Errorf("read session epoch: %w", err) | |
| 199 | + | } | |
| 200 | + | if *c.Epoch != epoch { | |
| 201 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 202 | + | } | |
| 203 | + | return domain.Owner(), nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // SignOutEverywhere ends every session by incrementing the epoch. Only the | |
| 207 | + | // owner may. Pending login links stay valid. | |
| 208 | + | func (s *Sessions) SignOutEverywhere(ctx context.Context, p domain.Principal) error { | |
| 209 | + | if err := requireOwner(p); err != nil { | |
| 210 | + | return err | |
| 211 | + | } | |
| 212 | + | epoch, err := s.store.BumpEpoch(ctx) | |
| 213 | + | if err != nil { | |
| 214 | + | if errors.Is(err, domain.ErrConflict) { | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | return fmt.Errorf("sign out everywhere: %w", err) | |
| 218 | + | } | |
| 219 | + | log := zerowrap.FromCtx(ctx) | |
| 220 | + | log.Info().Str("by", p.Label()).Str("by_token", p.TokenID()).Uint64("epoch", epoch).Msg("every web session signed out") | |
| 221 | + | return nil | |
| 222 | + | } | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "crypto/sha256" | |||
| 9 | + | "encoding/base64" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | "github.com/golang-jwt/jwt/v5" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Web login (ADR 0018). The owner asks for a one-time link with the API, opens | |||
| 23 | + | // it in a browser and confirms; the confirmation trades the link for a signed | |||
| 24 | + | // session token that the web keeps in an HttpOnly cookie. | |||
| 25 | + | const ( | |||
| 26 | + | // LoginLinkTTL is how long a login link works. | |||
| 27 | + | LoginLinkTTL = 60 * time.Second | |||
| 28 | + | // MaxPendingLoginLinks bounds the links that wait to be used, so that a | |||
| 29 | + | // loop of CreateLoginLink cannot grow the memory. | |||
| 30 | + | MaxPendingLoginLinks = 16 | |||
| 31 | + | // SessionTTL is the lifetime of a session. | |||
| 32 | + | SessionTTL = 12 * time.Hour | |||
| 33 | + | ||||
| 34 | + | // loginNonceBytes is the size of the secret in a link: 256 bits. | |||
| 35 | + | loginNonceBytes = 32 | |||
| 36 | + | // sessionSubject is the "sub" claim of every session: there is one owner. | |||
| 37 | + | sessionSubject = "owner" | |||
| 38 | + | // LoginPath is the path prefix of login links on the public URL. | |||
| 39 | + | LoginPath = "/-/login/" | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | // LoginLink is a login link as the owner receives it. | |||
| 43 | + | type LoginLink struct { | |||
| 44 | + | // URL holds the secret: show it once, never log it. | |||
| 45 | + | URL string | |||
| 46 | + | ExpiresAt time.Time | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Sessions are the use cases of the owner's browser sessions: login links, | |||
| 50 | + | // session tokens and signing out everywhere. Login links live in memory only, | |||
| 51 | + | // so a restart voids them; the signing key and the epoch are in the store. | |||
| 52 | + | type Sessions struct { | |||
| 53 | + | store ports.SessionStore | |||
| 54 | + | clock ports.Clock | |||
| 55 | + | publicURL string | |||
| 56 | + | key []byte | |||
| 57 | + | ||||
| 58 | + | mu sync.Mutex | |||
| 59 | + | links map[[sha256.Size]byte]time.Time // by hash of the nonce, to the expiry | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // NewSessions builds the session use cases and loads the signing key, which | |||
| 63 | + | // the store generates on first start. publicURL is the base URL the login | |||
| 64 | + | // links are built on. | |||
| 65 | + | func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Clock, publicURL string) (*Sessions, error) { | |||
| 66 | + | key, err := store.Key(ctx) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return nil, fmt.Errorf("load session key: %w", err) | |||
| 69 | + | } | |||
| 70 | + | return &Sessions{store: store, clock: clock, publicURL: publicURL, key: key, links: map[[sha256.Size]byte]time.Time{}}, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // requireOwner is the access rule of the session API: root, or a stored token | |||
| 74 | + | // with every scope and no repository list. | |||
| 75 | + | func requireOwner(p domain.Principal) error { | |||
| 76 | + | switch { | |||
| 77 | + | case p.IsAnonymous(): | |||
| 78 | + | return domain.ErrUnauthenticated | |||
| 79 | + | case !p.HasOwnerRights(): | |||
| 80 | + | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden) | |||
| 81 | + | } | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // CreateLoginLink makes a link valid for LoginLinkTTL and usable once. Only | |||
| 86 | + | // the owner may. It gives domain.ErrResourceExhausted when MaxPendingLoginLinks | |||
| 87 | + | // links wait. The nonce is never logged. | |||
| 88 | + | func (s *Sessions) CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error) { | |||
| 89 | + | if err := requireOwner(p); err != nil { | |||
| 90 | + | return LoginLink{}, err | |||
| 91 | + | } | |||
| 92 | + | var raw [loginNonceBytes]byte | |||
| 93 | + | if _, err := rand.Read(raw[:]); err != nil { | |||
| 94 | + | return LoginLink{}, fmt.Errorf("generate login link: %w", err) | |||
| 95 | + | } | |||
| 96 | + | nonce := base64.RawURLEncoding.EncodeToString(raw[:]) | |||
| 97 | + | now := s.clock.Now() | |||
| 98 | + | exp := now.Add(LoginLinkTTL) | |||
| 99 | + | ||||
| 100 | + | s.mu.Lock() | |||
| 101 | + | for h, e := range s.links { | |||
| 102 | + | if !now.Before(e) { | |||
| 103 | + | delete(s.links, h) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if len(s.links) >= MaxPendingLoginLinks { | |||
| 107 | + | s.mu.Unlock() | |||
| 108 | + | return LoginLink{}, fmt.Errorf("%w: %d login links are already pending, use one or wait a minute", domain.ErrResourceExhausted, MaxPendingLoginLinks) | |||
| 109 | + | } | |||
| 110 | + | s.links[sha256.Sum256([]byte(nonce))] = exp | |||
| 111 | + | s.mu.Unlock() | |||
| 112 | + | ||||
| 113 | + | log := zerowrap.FromCtx(ctx) | |||
| 114 | + | log.Info().Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).Time("expires_at", exp).Msg("login link created") | |||
| 115 | + | return LoginLink{URL: s.publicURL + LoginPath + nonce, ExpiresAt: exp.UTC()}, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // LoginLinkValid reports whether the nonce is a pending link, without using | |||
| 119 | + | // it: opening a link shows a confirmation, only confirming consumes it. | |||
| 120 | + | func (s *Sessions) LoginLinkValid(nonce string) bool { | |||
| 121 | + | h := sha256.Sum256([]byte(nonce)) | |||
| 122 | + | s.mu.Lock() | |||
| 123 | + | defer s.mu.Unlock() | |||
| 124 | + | exp, ok := s.links[h] | |||
| 125 | + | return ok && s.clock.Now().Before(exp) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Login consumes the link and opens a session: it returns the signed session | |||
| 129 | + | // token, for the cookie, and when it expires. A link that is unknown, expired | |||
| 130 | + | // or already used gives domain.ErrUnauthenticated, whatever the reason. The | |||
| 131 | + | // nonce and the token are never logged. | |||
| 132 | + | func (s *Sessions) Login(ctx context.Context, nonce string) (token string, expiresAt time.Time, err error) { | |||
| 133 | + | h := sha256.Sum256([]byte(nonce)) | |||
| 134 | + | s.mu.Lock() | |||
| 135 | + | exp, ok := s.links[h] | |||
| 136 | + | delete(s.links, h) // single use, also when expired | |||
| 137 | + | s.mu.Unlock() | |||
| 138 | + | if !ok || !s.clock.Now().Before(exp) { | |||
| 139 | + | return "", time.Time{}, domain.ErrUnauthenticated | |||
| 140 | + | } | |||
| 141 | + | token, expiresAt, err = s.issue(ctx) | |||
| 142 | + | if err != nil { | |||
| 143 | + | return "", time.Time{}, err | |||
| 144 | + | } | |||
| 145 | + | log := zerowrap.FromCtx(ctx) | |||
| 146 | + | log.Info().Time("expires_at", expiresAt).Msg("owner signed in on the web") | |||
| 147 | + | return token, expiresAt, nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // sessionClaims are the claims of a session token. The epoch is a pointer so | |||
| 151 | + | // that a token without one is not taken for epoch 0. | |||
| 152 | + | type sessionClaims struct { | |||
| 153 | + | Epoch *uint64 `json:"epoch"` | |||
| 154 | + | jwt.RegisteredClaims | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (s *Sessions) issue(ctx context.Context) (string, time.Time, error) { | |||
| 158 | + | epoch, err := s.store.Epoch(ctx) | |||
| 159 | + | if err != nil { | |||
| 160 | + | return "", time.Time{}, fmt.Errorf("read session epoch: %w", err) | |||
| 161 | + | } | |||
| 162 | + | now := s.clock.Now().UTC() | |||
| 163 | + | exp := now.Add(SessionTTL) | |||
| 164 | + | tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ | |||
| 165 | + | Epoch: &epoch, | |||
| 166 | + | RegisteredClaims: jwt.RegisteredClaims{ | |||
| 167 | + | Subject: sessionSubject, | |||
| 168 | + | IssuedAt: jwt.NewNumericDate(now), | |||
| 169 | + | ExpiresAt: jwt.NewNumericDate(exp), | |||
| 170 | + | }, | |||
| 171 | + | }).SignedString(s.key) | |||
| 172 | + | if err != nil { | |||
| 173 | + | return "", time.Time{}, fmt.Errorf("sign session: %w", err) | |||
| 174 | + | } | |||
| 175 | + | return tok, exp.Truncate(time.Second), nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Verify checks a session token and returns the owner. The signature must be | |||
| 179 | + | // HS256 with the session key (the algorithm is pinned, so "none" and any other | |||
| 180 | + | // are refused), the token must not be expired or issued in the future, and its | |||
| 181 | + | // epoch must be the current one. Any such failure gives | |||
| 182 | + | // domain.ErrUnauthenticated; a store failure is returned as it is, so that the | |||
| 183 | + | // caller fails closed. | |||
| 184 | + | func (s *Sessions) Verify(ctx context.Context, token string) (domain.Principal, error) { | |||
| 185 | + | var c sessionClaims | |||
| 186 | + | _, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return s.key, nil }, | |||
| 187 | + | jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), | |||
| 188 | + | jwt.WithExpirationRequired(), | |||
| 189 | + | jwt.WithIssuedAt(), | |||
| 190 | + | jwt.WithSubject(sessionSubject), | |||
| 191 | + | jwt.WithTimeFunc(s.clock.Now), | |||
| 192 | + | ) | |||
| 193 | + | if err != nil || c.Epoch == nil { | |||
| 194 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 195 | + | } | |||
| 196 | + | epoch, err := s.store.Epoch(ctx) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return domain.Principal{}, fmt.Errorf("read session epoch: %w", err) | |||
| 199 | + | } | |||
| 200 | + | if *c.Epoch != epoch { | |||
| 201 | + | return domain.Principal{}, domain.ErrUnauthenticated | |||
| 202 | + | } | |||
| 203 | + | return domain.Owner(), nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // SignOutEverywhere ends every session by incrementing the epoch. Only the | |||
| 207 | + | // owner may. Pending login links stay valid. | |||
| 208 | + | func (s *Sessions) SignOutEverywhere(ctx context.Context, p domain.Principal) error { | |||
| 209 | + | if err := requireOwner(p); err != nil { | |||
| 210 | + | return err | |||
| 211 | + | } | |||
| 212 | + | epoch, err := s.store.BumpEpoch(ctx) | |||
| 213 | + | if err != nil { | |||
| 214 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | return fmt.Errorf("sign out everywhere: %w", err) | |||
| 218 | + | } | |||
| 219 | + | log := zerowrap.FromCtx(ctx) | |||
| 220 | + | log.Info().Str("by", p.Label()).Str("by_token", p.TokenID()).Uint64("epoch", epoch).Msg("every web session signed out") | |||
| 221 | + | return nil | |||
| 222 | + | } | |||
A internal/app/sessions_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/golang-jwt/jwt/v5" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/app" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // memSessions is an in-memory ports.SessionStore. | |
| 21 | + | type memSessions struct { | |
| 22 | + | mu sync.Mutex | |
| 23 | + | key []byte | |
| 24 | + | epoch uint64 | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil } | |
| 28 | + | func (m *memSessions) Epoch(context.Context) (uint64, error) { | |
| 29 | + | m.mu.Lock() | |
| 30 | + | defer m.mu.Unlock() | |
| 31 | + | return m.epoch, nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (m *memSessions) BumpEpoch(context.Context) (uint64, error) { | |
| 35 | + | m.mu.Lock() | |
| 36 | + | defer m.mu.Unlock() | |
| 37 | + | m.epoch++ | |
| 38 | + | return m.epoch, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // testClock is a clock the test moves. | |
| 42 | + | type testClock struct { | |
| 43 | + | mu sync.Mutex | |
| 44 | + | t time.Time | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *testClock) Now() time.Time { | |
| 48 | + | c.mu.Lock() | |
| 49 | + | defer c.mu.Unlock() | |
| 50 | + | return c.t | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (c *testClock) advance(d time.Duration) { | |
| 54 | + | c.mu.Lock() | |
| 55 | + | defer c.mu.Unlock() | |
| 56 | + | c.t = c.t.Add(d) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type sessionFixture struct { | |
| 60 | + | store *memSessions | |
| 61 | + | clock *testClock | |
| 62 | + | sessions *app.Sessions | |
| 63 | + | key []byte | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func newSessionFixture(t *testing.T) *sessionFixture { | |
| 67 | + | t.Helper() | |
| 68 | + | f := &sessionFixture{ | |
| 69 | + | store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")}, | |
| 70 | + | clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}, | |
| 71 | + | } | |
| 72 | + | f.key = f.store.key | |
| 73 | + | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | f.sessions = s | |
| 76 | + | return f | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (f *sessionFixture) nonce(t *testing.T) string { | |
| 80 | + | t.Helper() | |
| 81 | + | link, err := f.sessions.CreateLoginLink(context.Background(), domain.Root()) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | n, ok := strings.CutPrefix(link.URL, "https://forge.example/-/login/") | |
| 84 | + | require.True(t, ok, link.URL) | |
| 85 | + | return n | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func allScopeToken(repos ...domain.RepoName) domain.Principal { | |
| 89 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: repos}) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Only the owner creates a link: root, or a token with every scope and no | |
| 93 | + | // repository list. | |
| 94 | + | func TestLoginLinkIsOwnerOnly(t *testing.T) { | |
| 95 | + | ctx := context.Background() | |
| 96 | + | f := newSessionFixture(t) | |
| 97 | + | partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]}) | |
| 98 | + | limited := allScopeToken(mustRepo(t, "a")) | |
| 99 | + | ||
| 100 | + | for name, tc := range map[string]struct { | |
| 101 | + | p domain.Principal | |
| 102 | + | err error | |
| 103 | + | }{ | |
| 104 | + | "root": {domain.Root(), nil}, | |
| 105 | + | "owner session": {domain.Owner(), nil}, | |
| 106 | + | "every scope": {allScopeToken(), nil}, | |
| 107 | + | "one scope short": {partial, domain.ErrForbidden}, | |
| 108 | + | "repo list": {limited, domain.ErrForbidden}, | |
| 109 | + | "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated}, | |
| 110 | + | } { | |
| 111 | + | _, err := f.sessions.CreateLoginLink(ctx, tc.p) | |
| 112 | + | if tc.err == nil { | |
| 113 | + | assert.NoError(t, err, name) | |
| 114 | + | } else { | |
| 115 | + | assert.ErrorIs(t, err, tc.err, name) | |
| 116 | + | } | |
| 117 | + | err = f.sessions.SignOutEverywhere(ctx, tc.p) | |
| 118 | + | if tc.err == nil { | |
| 119 | + | assert.NoError(t, err, name) | |
| 120 | + | } else { | |
| 121 | + | assert.ErrorIs(t, err, tc.err, name) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func mustRepo(t *testing.T, s string) domain.RepoName { | |
| 127 | + | t.Helper() | |
| 128 | + | n, err := domain.ParseRepoName(s) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | return n | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // AC1: checking a link does not use it; logging in uses it once; an expired or | |
| 134 | + | // replayed link fails. | |
| 135 | + | func TestLoginLinkLifecycle(t *testing.T) { | |
| 136 | + | ctx := context.Background() | |
| 137 | + | f := newSessionFixture(t) | |
| 138 | + | ||
| 139 | + | link, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt) | |
| 142 | + | nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/") | |
| 143 | + | assert.Len(t, nonce, 43, "256 bits") | |
| 144 | + | ||
| 145 | + | for range 3 { | |
| 146 | + | assert.True(t, f.sessions.LoginLinkValid(nonce), "looking never consumes") | |
| 147 | + | } | |
| 148 | + | token, exp, err := f.sessions.Login(ctx, nonce) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | assert.NotEmpty(t, token) | |
| 151 | + | assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp) | |
| 152 | + | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |
| 153 | + | _, _, err = f.sessions.Login(ctx, nonce) | |
| 154 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay") | |
| 155 | + | ||
| 156 | + | // Expired. | |
| 157 | + | nonce = f.nonce(t) | |
| 158 | + | f.clock.advance(app.LoginLinkTTL) | |
| 159 | + | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |
| 160 | + | _, _, err = f.sessions.Login(ctx, nonce) | |
| 161 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | |
| 162 | + | ||
| 163 | + | // Unknown. | |
| 164 | + | _, _, err = f.sessions.Login(ctx, strings.Repeat("a", 43)) | |
| 165 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 166 | + | ||
| 167 | + | // Two links are independent. | |
| 168 | + | a, b := f.nonce(t), f.nonce(t) | |
| 169 | + | assert.NotEqual(t, a, b) | |
| 170 | + | _, _, err = f.sessions.Login(ctx, a) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | assert.True(t, f.sessions.LoginLinkValid(b)) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // A single use also holds under concurrent confirmations. | |
| 176 | + | func TestLoginLinkConsumedOnceConcurrently(t *testing.T) { | |
| 177 | + | f := newSessionFixture(t) | |
| 178 | + | nonce := f.nonce(t) | |
| 179 | + | var wg sync.WaitGroup | |
| 180 | + | var mu sync.Mutex | |
| 181 | + | won := 0 | |
| 182 | + | for range 16 { | |
| 183 | + | wg.Add(1) | |
| 184 | + | go func() { | |
| 185 | + | defer wg.Done() | |
| 186 | + | if _, _, err := f.sessions.Login(context.Background(), nonce); err == nil { | |
| 187 | + | mu.Lock() | |
| 188 | + | won++ | |
| 189 | + | mu.Unlock() | |
| 190 | + | } | |
| 191 | + | }() | |
| 192 | + | } | |
| 193 | + | wg.Wait() | |
| 194 | + | assert.Equal(t, 1, won) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestLoginLinksAreBounded(t *testing.T) { | |
| 198 | + | ctx := context.Background() | |
| 199 | + | f := newSessionFixture(t) | |
| 200 | + | for range app.MaxPendingLoginLinks { | |
| 201 | + | f.nonce(t) | |
| 202 | + | } | |
| 203 | + | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 204 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 205 | + | ||
| 206 | + | f.clock.advance(app.LoginLinkTTL) | |
| 207 | + | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | |
| 208 | + | assert.NoError(t, err, "expired links make room") | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func sign(t *testing.T, m jwt.SigningMethod, key any, claims jwt.Claims) string { | |
| 212 | + | t.Helper() | |
| 213 | + | s, err := jwt.NewWithClaims(m, claims).SignedString(key) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | return s | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | type claims struct { | |
| 219 | + | Epoch *uint64 `json:"epoch"` | |
| 220 | + | jwt.RegisteredClaims | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // AC1: a valid session is the owner; none, alg none, another algorithm, another | |
| 224 | + | // key, an old epoch, expiry, a missing claim and garbage are rejected. | |
| 225 | + | func TestSessionTokenVerification(t *testing.T) { | |
| 226 | + | ctx := context.Background() | |
| 227 | + | f := newSessionFixture(t) | |
| 228 | + | now := f.clock.Now() | |
| 229 | + | zero, one := uint64(0), uint64(1) | |
| 230 | + | reg := func(sub string, iat, exp time.Time) jwt.RegisteredClaims { | |
| 231 | + | return jwt.RegisteredClaims{Subject: sub, IssuedAt: jwt.NewNumericDate(iat), ExpiresAt: jwt.NewNumericDate(exp)} | |
| 232 | + | } | |
| 233 | + | good := reg("owner", now, now.Add(time.Hour)) | |
| 234 | + | ||
| 235 | + | token, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | p, err := f.sessions.Verify(ctx, token) | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | assert.True(t, p.IsOwner()) | |
| 240 | + | assert.Equal(t, "owner", p.Label()) | |
| 241 | + | ||
| 242 | + | cases := map[string]string{ | |
| 243 | + | "alg none": sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, claims{&zero, good}), | |
| 244 | + | "hs384": sign(t, jwt.SigningMethodHS384, f.key, claims{&zero, good}), | |
| 245 | + | "hs512": sign(t, jwt.SigningMethodHS512, f.key, claims{&zero, good}), | |
| 246 | + | "wrong key": sign(t, jwt.SigningMethodHS256, []byte("another key of 32 bytes.........."), claims{&zero, good}), | |
| 247 | + | "old epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{&one, good}), | |
| 248 | + | "no epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{nil, good}), | |
| 249 | + | "expired": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(-2*time.Hour), now.Add(-time.Hour))}), | |
| 250 | + | "no expiry": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, jwt.RegisteredClaims{Subject: "owner", IssuedAt: jwt.NewNumericDate(now)}}), | |
| 251 | + | "future iat": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(time.Hour), now.Add(2*time.Hour))}), | |
| 252 | + | "other subject": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("someone", now, now.Add(time.Hour))}), | |
| 253 | + | "empty": "", | |
| 254 | + | "garbage": "not.a.jwt", | |
| 255 | + | "truncated": token[:len(token)-3], | |
| 256 | + | "signature swap": token[:strings.LastIndex(token, ".")] + ".AAAA", | |
| 257 | + | "payload only": strings.Split(token, ".")[1], | |
| 258 | + | "three empty": "..", | |
| 259 | + | } | |
| 260 | + | for name, tok := range cases { | |
| 261 | + | _, err := f.sessions.Verify(ctx, tok) | |
| 262 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, name) | |
| 263 | + | } | |
| 264 | + | // The control: the same claims signed right are accepted. | |
| 265 | + | _, err = f.sessions.Verify(ctx, sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, good})) | |
| 266 | + | assert.NoError(t, err) | |
| 267 | + | ||
| 268 | + | // Expiry after the session lifetime; and the epoch ends every session. | |
| 269 | + | f.clock.advance(app.SessionTTL) | |
| 270 | + | _, err = f.sessions.Verify(ctx, token) | |
| 271 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestSignOutEverywhereEndsSessions(t *testing.T) { | |
| 275 | + | ctx := context.Background() | |
| 276 | + | f := newSessionFixture(t) | |
| 277 | + | a, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | b, _, err := f.sessions.Login(ctx, f.nonce(t)) | |
| 280 | + | require.NoError(t, err) | |
| 281 | + | pending := f.nonce(t) | |
| 282 | + | ||
| 283 | + | require.NoError(t, f.sessions.SignOutEverywhere(ctx, domain.Root())) | |
| 284 | + | for _, tok := range []string{a, b} { | |
| 285 | + | _, err := f.sessions.Verify(ctx, tok) | |
| 286 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 287 | + | } | |
| 288 | + | assert.True(t, f.sessions.LoginLinkValid(pending), "pending links are not affected") | |
| 289 | + | c, _, err := f.sessions.Login(ctx, pending) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | _, err = f.sessions.Verify(ctx, c) | |
| 292 | + | assert.NoError(t, err, "a new session works") | |
| 293 | + | } | |
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/golang-jwt/jwt/v5" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/app" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // memSessions is an in-memory ports.SessionStore. | |||
| 21 | + | type memSessions struct { | |||
| 22 | + | mu sync.Mutex | |||
| 23 | + | key []byte | |||
| 24 | + | epoch uint64 | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil } | |||
| 28 | + | func (m *memSessions) Epoch(context.Context) (uint64, error) { | |||
| 29 | + | m.mu.Lock() | |||
| 30 | + | defer m.mu.Unlock() | |||
| 31 | + | return m.epoch, nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (m *memSessions) BumpEpoch(context.Context) (uint64, error) { | |||
| 35 | + | m.mu.Lock() | |||
| 36 | + | defer m.mu.Unlock() | |||
| 37 | + | m.epoch++ | |||
| 38 | + | return m.epoch, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // testClock is a clock the test moves. | |||
| 42 | + | type testClock struct { | |||
| 43 | + | mu sync.Mutex | |||
| 44 | + | t time.Time | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *testClock) Now() time.Time { | |||
| 48 | + | c.mu.Lock() | |||
| 49 | + | defer c.mu.Unlock() | |||
| 50 | + | return c.t | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (c *testClock) advance(d time.Duration) { | |||
| 54 | + | c.mu.Lock() | |||
| 55 | + | defer c.mu.Unlock() | |||
| 56 | + | c.t = c.t.Add(d) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type sessionFixture struct { | |||
| 60 | + | store *memSessions | |||
| 61 | + | clock *testClock | |||
| 62 | + | sessions *app.Sessions | |||
| 63 | + | key []byte | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func newSessionFixture(t *testing.T) *sessionFixture { | |||
| 67 | + | t.Helper() | |||
| 68 | + | f := &sessionFixture{ | |||
| 69 | + | store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")}, | |||
| 70 | + | clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}, | |||
| 71 | + | } | |||
| 72 | + | f.key = f.store.key | |||
| 73 | + | s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example") | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | f.sessions = s | |||
| 76 | + | return f | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (f *sessionFixture) nonce(t *testing.T) string { | |||
| 80 | + | t.Helper() | |||
| 81 | + | link, err := f.sessions.CreateLoginLink(context.Background(), domain.Root()) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | n, ok := strings.CutPrefix(link.URL, "https://forge.example/-/login/") | |||
| 84 | + | require.True(t, ok, link.URL) | |||
| 85 | + | return n | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func allScopeToken(repos ...domain.RepoName) domain.Principal { | |||
| 89 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: repos}) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Only the owner creates a link: root, or a token with every scope and no | |||
| 93 | + | // repository list. | |||
| 94 | + | func TestLoginLinkIsOwnerOnly(t *testing.T) { | |||
| 95 | + | ctx := context.Background() | |||
| 96 | + | f := newSessionFixture(t) | |||
| 97 | + | partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]}) | |||
| 98 | + | limited := allScopeToken(mustRepo(t, "a")) | |||
| 99 | + | ||||
| 100 | + | for name, tc := range map[string]struct { | |||
| 101 | + | p domain.Principal | |||
| 102 | + | err error | |||
| 103 | + | }{ | |||
| 104 | + | "root": {domain.Root(), nil}, | |||
| 105 | + | "owner session": {domain.Owner(), nil}, | |||
| 106 | + | "every scope": {allScopeToken(), nil}, | |||
| 107 | + | "one scope short": {partial, domain.ErrForbidden}, | |||
| 108 | + | "repo list": {limited, domain.ErrForbidden}, | |||
| 109 | + | "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated}, | |||
| 110 | + | } { | |||
| 111 | + | _, err := f.sessions.CreateLoginLink(ctx, tc.p) | |||
| 112 | + | if tc.err == nil { | |||
| 113 | + | assert.NoError(t, err, name) | |||
| 114 | + | } else { | |||
| 115 | + | assert.ErrorIs(t, err, tc.err, name) | |||
| 116 | + | } | |||
| 117 | + | err = f.sessions.SignOutEverywhere(ctx, tc.p) | |||
| 118 | + | if tc.err == nil { | |||
| 119 | + | assert.NoError(t, err, name) | |||
| 120 | + | } else { | |||
| 121 | + | assert.ErrorIs(t, err, tc.err, name) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func mustRepo(t *testing.T, s string) domain.RepoName { | |||
| 127 | + | t.Helper() | |||
| 128 | + | n, err := domain.ParseRepoName(s) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | return n | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // AC1: checking a link does not use it; logging in uses it once; an expired or | |||
| 134 | + | // replayed link fails. | |||
| 135 | + | func TestLoginLinkLifecycle(t *testing.T) { | |||
| 136 | + | ctx := context.Background() | |||
| 137 | + | f := newSessionFixture(t) | |||
| 138 | + | ||||
| 139 | + | link, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt) | |||
| 142 | + | nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/") | |||
| 143 | + | assert.Len(t, nonce, 43, "256 bits") | |||
| 144 | + | ||||
| 145 | + | for range 3 { | |||
| 146 | + | assert.True(t, f.sessions.LoginLinkValid(nonce), "looking never consumes") | |||
| 147 | + | } | |||
| 148 | + | token, exp, err := f.sessions.Login(ctx, nonce) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | assert.NotEmpty(t, token) | |||
| 151 | + | assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp) | |||
| 152 | + | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |||
| 153 | + | _, _, err = f.sessions.Login(ctx, nonce) | |||
| 154 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay") | |||
| 155 | + | ||||
| 156 | + | // Expired. | |||
| 157 | + | nonce = f.nonce(t) | |||
| 158 | + | f.clock.advance(app.LoginLinkTTL) | |||
| 159 | + | assert.False(t, f.sessions.LoginLinkValid(nonce)) | |||
| 160 | + | _, _, err = f.sessions.Login(ctx, nonce) | |||
| 161 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired") | |||
| 162 | + | ||||
| 163 | + | // Unknown. | |||
| 164 | + | _, _, err = f.sessions.Login(ctx, strings.Repeat("a", 43)) | |||
| 165 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 166 | + | ||||
| 167 | + | // Two links are independent. | |||
| 168 | + | a, b := f.nonce(t), f.nonce(t) | |||
| 169 | + | assert.NotEqual(t, a, b) | |||
| 170 | + | _, _, err = f.sessions.Login(ctx, a) | |||
| 171 | + | require.NoError(t, err) | |||
| 172 | + | assert.True(t, f.sessions.LoginLinkValid(b)) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // A single use also holds under concurrent confirmations. | |||
| 176 | + | func TestLoginLinkConsumedOnceConcurrently(t *testing.T) { | |||
| 177 | + | f := newSessionFixture(t) | |||
| 178 | + | nonce := f.nonce(t) | |||
| 179 | + | var wg sync.WaitGroup | |||
| 180 | + | var mu sync.Mutex | |||
| 181 | + | won := 0 | |||
| 182 | + | for range 16 { | |||
| 183 | + | wg.Add(1) | |||
| 184 | + | go func() { | |||
| 185 | + | defer wg.Done() | |||
| 186 | + | if _, _, err := f.sessions.Login(context.Background(), nonce); err == nil { | |||
| 187 | + | mu.Lock() | |||
| 188 | + | won++ | |||
| 189 | + | mu.Unlock() | |||
| 190 | + | } | |||
| 191 | + | }() | |||
| 192 | + | } | |||
| 193 | + | wg.Wait() | |||
| 194 | + | assert.Equal(t, 1, won) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestLoginLinksAreBounded(t *testing.T) { | |||
| 198 | + | ctx := context.Background() | |||
| 199 | + | f := newSessionFixture(t) | |||
| 200 | + | for range app.MaxPendingLoginLinks { | |||
| 201 | + | f.nonce(t) | |||
| 202 | + | } | |||
| 203 | + | _, err := f.sessions.CreateLoginLink(ctx, domain.Root()) | |||
| 204 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 205 | + | ||||
| 206 | + | f.clock.advance(app.LoginLinkTTL) | |||
| 207 | + | _, err = f.sessions.CreateLoginLink(ctx, domain.Root()) | |||
| 208 | + | assert.NoError(t, err, "expired links make room") | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func sign(t *testing.T, m jwt.SigningMethod, key any, claims jwt.Claims) string { | |||
| 212 | + | t.Helper() | |||
| 213 | + | s, err := jwt.NewWithClaims(m, claims).SignedString(key) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | return s | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | type claims struct { | |||
| 219 | + | Epoch *uint64 `json:"epoch"` | |||
| 220 | + | jwt.RegisteredClaims | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // AC1: a valid session is the owner; none, alg none, another algorithm, another | |||
| 224 | + | // key, an old epoch, expiry, a missing claim and garbage are rejected. | |||
| 225 | + | func TestSessionTokenVerification(t *testing.T) { | |||
| 226 | + | ctx := context.Background() | |||
| 227 | + | f := newSessionFixture(t) | |||
| 228 | + | now := f.clock.Now() | |||
| 229 | + | zero, one := uint64(0), uint64(1) | |||
| 230 | + | reg := func(sub string, iat, exp time.Time) jwt.RegisteredClaims { | |||
| 231 | + | return jwt.RegisteredClaims{Subject: sub, IssuedAt: jwt.NewNumericDate(iat), ExpiresAt: jwt.NewNumericDate(exp)} | |||
| 232 | + | } | |||
| 233 | + | good := reg("owner", now, now.Add(time.Hour)) | |||
| 234 | + | ||||
| 235 | + | token, _, err := f.sessions.Login(ctx, f.nonce(t)) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | p, err := f.sessions.Verify(ctx, token) | |||
| 238 | + | require.NoError(t, err) | |||
| 239 | + | assert.True(t, p.IsOwner()) | |||
| 240 | + | assert.Equal(t, "owner", p.Label()) | |||
| 241 | + | ||||
| 242 | + | cases := map[string]string{ | |||
| 243 | + | "alg none": sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, claims{&zero, good}), | |||
| 244 | + | "hs384": sign(t, jwt.SigningMethodHS384, f.key, claims{&zero, good}), | |||
| 245 | + | "hs512": sign(t, jwt.SigningMethodHS512, f.key, claims{&zero, good}), | |||
| 246 | + | "wrong key": sign(t, jwt.SigningMethodHS256, []byte("another key of 32 bytes.........."), claims{&zero, good}), | |||
| 247 | + | "old epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{&one, good}), | |||
| 248 | + | "no epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{nil, good}), | |||
| 249 | + | "expired": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(-2*time.Hour), now.Add(-time.Hour))}), | |||
| 250 | + | "no expiry": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, jwt.RegisteredClaims{Subject: "owner", IssuedAt: jwt.NewNumericDate(now)}}), | |||
| 251 | + | "future iat": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(time.Hour), now.Add(2*time.Hour))}), | |||
| 252 | + | "other subject": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("someone", now, now.Add(time.Hour))}), | |||
| 253 | + | "empty": "", | |||
| 254 | + | "garbage": "not.a.jwt", | |||
| 255 | + | "truncated": token[:len(token)-3], | |||
| 256 | + | "signature swap": token[:strings.LastIndex(token, ".")] + ".AAAA", | |||
| 257 | + | "payload only": strings.Split(token, ".")[1], | |||
| 258 | + | "three empty": "..", | |||
| 259 | + | } | |||
| 260 | + | for name, tok := range cases { | |||
| 261 | + | _, err := f.sessions.Verify(ctx, tok) | |||
| 262 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, name) | |||
| 263 | + | } | |||
| 264 | + | // The control: the same claims signed right are accepted. | |||
| 265 | + | _, err = f.sessions.Verify(ctx, sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, good})) | |||
| 266 | + | assert.NoError(t, err) | |||
| 267 | + | ||||
| 268 | + | // Expiry after the session lifetime; and the epoch ends every session. | |||
| 269 | + | f.clock.advance(app.SessionTTL) | |||
| 270 | + | _, err = f.sessions.Verify(ctx, token) | |||
| 271 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h") | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestSignOutEverywhereEndsSessions(t *testing.T) { | |||
| 275 | + | ctx := context.Background() | |||
| 276 | + | f := newSessionFixture(t) | |||
| 277 | + | a, _, err := f.sessions.Login(ctx, f.nonce(t)) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | b, _, err := f.sessions.Login(ctx, f.nonce(t)) | |||
| 280 | + | require.NoError(t, err) | |||
| 281 | + | pending := f.nonce(t) | |||
| 282 | + | ||||
| 283 | + | require.NoError(t, f.sessions.SignOutEverywhere(ctx, domain.Root())) | |||
| 284 | + | for _, tok := range []string{a, b} { | |||
| 285 | + | _, err := f.sessions.Verify(ctx, tok) | |||
| 286 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 287 | + | } | |||
| 288 | + | assert.True(t, f.sessions.LoginLinkValid(pending), "pending links are not affected") | |||
| 289 | + | c, _, err := f.sessions.Login(ctx, pending) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | _, err = f.sessions.Verify(ctx, c) | |||
| 292 | + | assert.NoError(t, err, "a new session works") | |||
| 293 | + | } | |||
M internal/domain/principal.go +38 -7
| @@ -15,10 +15,12 @@ const ( | |||
| 15 | 15 | kindAnonymous principalKind = iota | |
| 16 | 16 | kindRoot | |
| 17 | 17 | kindToken | |
| 18 | + | kindOwner | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // Principal is the authenticated identity of a caller: nobody, the root | |
| 21 | - | // token, or a stored token with its scopes and repositories. | |
| 22 | + | // token, the owner signed in on the web, or a stored token with its scopes | |
| 23 | + | // and repositories. | |
| 22 | 24 | type Principal struct { | |
| 23 | 25 | kind principalKind | |
| 24 | 26 | tokenID string | |
| @@ -34,6 +36,11 @@ func Anonymous() Principal { return Principal{kind: kindAnonymous} } | |||
| 34 | 36 | // Root returns the principal of the admin token holder. | |
| 35 | 37 | func Root() Principal { return Principal{kind: kindRoot} } | |
| 36 | 38 | ||
| 39 | + | // Owner returns the principal of the owner's web session (ADR 0018): the | |
| 40 | + | // rights of root, labelled "owner" in logs and messages. Only the web | |
| 41 | + | // middleware creates it, for a valid session cookie; no secret maps to it. | |
| 42 | + | func Owner() Principal { return Principal{kind: kindOwner} } | |
| 43 | + | ||
| 37 | 44 | // TokenPrincipal returns the principal of the holder of a stored token. | |
| 38 | 45 | func TokenPrincipal(t Token) Principal { | |
| 39 | 46 | return Principal{ | |
| @@ -47,19 +54,43 @@ func TokenPrincipal(t Token) Principal { | |||
| 47 | 54 | } | |
| 48 | 55 | ||
| 49 | 56 | // Label names the kind of principal for logs and messages: "anonymous", | |
| 50 | - | // "root" or "token". | |
| 57 | + | // "root", "owner" or "token". | |
| 51 | 58 | func (p Principal) Label() string { | |
| 52 | 59 | switch p.kind { | |
| 53 | 60 | case kindRoot: | |
| 54 | 61 | return "root" | |
| 62 | + | case kindOwner: | |
| 63 | + | return "owner" | |
| 55 | 64 | case kindToken: | |
| 56 | 65 | return "token" | |
| 57 | 66 | } | |
| 58 | 67 | return "anonymous" | |
| 59 | 68 | } | |
| 60 | 69 | ||
| 61 | - | // IsRoot reports whether p may do everything. | |
| 62 | - | func (p Principal) IsRoot() bool { return p.kind == kindRoot } | |
| 70 | + | // IsRoot reports whether p may do everything: the root token, or the owner | |
| 71 | + | // signed in on the web. | |
| 72 | + | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | |
| 73 | + | ||
| 74 | + | // HasOwnerRights reports whether p is the owner: root, the owner's web | |
| 75 | + | // session, or a stored token that holds every scope and is not limited to a | |
| 76 | + | // list of repositories. It decides who may manage browser sessions. | |
| 77 | + | func (p Principal) HasOwnerRights() bool { | |
| 78 | + | if p.IsRoot() { | |
| 79 | + | return true | |
| 80 | + | } | |
| 81 | + | if p.kind != kindToken || len(p.repos) > 0 { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | for _, s := range AllScopes() { | |
| 85 | + | if !slices.Contains(p.scopes, s) { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // IsOwner reports whether p is the owner signed in on the web. | |
| 93 | + | func (p Principal) IsOwner() bool { return p.kind == kindOwner } | |
| 63 | 94 | ||
| 64 | 95 | // IsAnonymous reports whether p has no credentials. | |
| 65 | 96 | func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous } | |
| @@ -77,11 +108,11 @@ func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | |||
| 77 | 108 | // ExpiresAt returns the expiry of a stored token; zero means none. | |
| 78 | 109 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | |
| 79 | 110 | ||
| 80 | - | // HasScope reports whether p holds the scope, for some repository. Root holds | |
| 81 | - | // every scope; anonymous none. | |
| 111 | + | // HasScope reports whether p holds the scope, for some repository. Root and | |
| 112 | + | // the owner hold every scope; anonymous none. | |
| 82 | 113 | func (p Principal) HasScope(s Scope) bool { | |
| 83 | 114 | switch p.kind { | |
| 84 | - | case kindRoot: | |
| 115 | + | case kindRoot, kindOwner: | |
| 85 | 116 | return true | |
| 86 | 117 | case kindToken: | |
| 87 | 118 | return slices.Contains(p.scopes, s) | |
| @@ -15,10 +15,12 @@ const ( | |||||
| 15 | kindAnonymous principalKind = iota | 15 | kindAnonymous principalKind = iota | ||
| 16 | kindRoot | 16 | kindRoot | ||
| 17 | kindToken | 17 | kindToken | ||
| 18 | + | kindOwner | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | // Principal is the authenticated identity of a caller: nobody, the root | 21 | // Principal is the authenticated identity of a caller: nobody, the root | ||
| 21 | - | // token, or a stored token with its scopes and repositories. | 22 | + | // token, the owner signed in on the web, or a stored token with its scopes |
| 23 | + | // and repositories. | |||
| 22 | type Principal struct { | 24 | type Principal struct { | ||
| 23 | kind principalKind | 25 | kind principalKind | ||
| 24 | tokenID string | 26 | tokenID string | ||
| @@ -34,6 +36,11 @@ func Anonymous() Principal { return Principal{kind: kindAnonymous} } | |||||
| 34 | // Root returns the principal of the admin token holder. | 36 | // Root returns the principal of the admin token holder. | ||
| 35 | func Root() Principal { return Principal{kind: kindRoot} } | 37 | func Root() Principal { return Principal{kind: kindRoot} } | ||
| 36 | 38 | ||||
| 39 | + | // Owner returns the principal of the owner's web session (ADR 0018): the | |||
| 40 | + | // rights of root, labelled "owner" in logs and messages. Only the web | |||
| 41 | + | // middleware creates it, for a valid session cookie; no secret maps to it. | |||
| 42 | + | func Owner() Principal { return Principal{kind: kindOwner} } | |||
| 43 | + | ||||
| 37 | // TokenPrincipal returns the principal of the holder of a stored token. | 44 | // TokenPrincipal returns the principal of the holder of a stored token. | ||
| 38 | func TokenPrincipal(t Token) Principal { | 45 | func TokenPrincipal(t Token) Principal { | ||
| 39 | return Principal{ | 46 | return Principal{ | ||
| @@ -47,19 +54,43 @@ func TokenPrincipal(t Token) Principal { | |||||
| 47 | } | 54 | } | ||
| 48 | 55 | ||||
| 49 | // Label names the kind of principal for logs and messages: "anonymous", | 56 | // Label names the kind of principal for logs and messages: "anonymous", | ||
| 50 | - | // "root" or "token". | 57 | + | // "root", "owner" or "token". |
| 51 | func (p Principal) Label() string { | 58 | func (p Principal) Label() string { | ||
| 52 | switch p.kind { | 59 | switch p.kind { | ||
| 53 | case kindRoot: | 60 | case kindRoot: | ||
| 54 | return "root" | 61 | return "root" | ||
| 62 | + | case kindOwner: | |||
| 63 | + | return "owner" | |||
| 55 | case kindToken: | 64 | case kindToken: | ||
| 56 | return "token" | 65 | return "token" | ||
| 57 | } | 66 | } | ||
| 58 | return "anonymous" | 67 | return "anonymous" | ||
| 59 | } | 68 | } | ||
| 60 | 69 | ||||
| 61 | - | // IsRoot reports whether p may do everything. | 70 | + | // IsRoot reports whether p may do everything: the root token, or the owner |
| 62 | - | func (p Principal) IsRoot() bool { return p.kind == kindRoot } | 71 | + | // signed in on the web. |
| 72 | + | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | |||
| 73 | + | ||||
| 74 | + | // HasOwnerRights reports whether p is the owner: root, the owner's web | |||
| 75 | + | // session, or a stored token that holds every scope and is not limited to a | |||
| 76 | + | // list of repositories. It decides who may manage browser sessions. | |||
| 77 | + | func (p Principal) HasOwnerRights() bool { | |||
| 78 | + | if p.IsRoot() { | |||
| 79 | + | return true | |||
| 80 | + | } | |||
| 81 | + | if p.kind != kindToken || len(p.repos) > 0 { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | for _, s := range AllScopes() { | |||
| 85 | + | if !slices.Contains(p.scopes, s) { | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // IsOwner reports whether p is the owner signed in on the web. | |||
| 93 | + | func (p Principal) IsOwner() bool { return p.kind == kindOwner } | |||
| 63 | 94 | ||||
| 64 | // IsAnonymous reports whether p has no credentials. | 95 | // IsAnonymous reports whether p has no credentials. | ||
| 65 | func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous } | 96 | func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous } | ||
| @@ -77,11 +108,11 @@ func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | |||||
| 77 | // ExpiresAt returns the expiry of a stored token; zero means none. | 108 | // ExpiresAt returns the expiry of a stored token; zero means none. | ||
| 78 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | 109 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | ||
| 79 | 110 | ||||
| 80 | - | // HasScope reports whether p holds the scope, for some repository. Root holds | 111 | + | // HasScope reports whether p holds the scope, for some repository. Root and |
| 81 | - | // every scope; anonymous none. | 112 | + | // the owner hold every scope; anonymous none. |
| 82 | func (p Principal) HasScope(s Scope) bool { | 113 | func (p Principal) HasScope(s Scope) bool { | ||
| 83 | switch p.kind { | 114 | switch p.kind { | ||
| 84 | - | case kindRoot: | 115 | + | case kindRoot, kindOwner: |
| 85 | return true | 116 | return true | ||
| 86 | case kindToken: | 117 | case kindToken: | ||
| 87 | return slices.Contains(p.scopes, s) | 118 | return slices.Contains(p.scopes, s) | ||
M internal/domain/token_test.go +23 -0
| @@ -82,3 +82,26 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||
| 82 | 82 | require.NoError(t, err) | |
| 83 | 83 | return n | |
| 84 | 84 | } | |
| 85 | + | ||
| 86 | + | func TestOwnerPrincipal(t *testing.T) { | |
| 87 | + | o := domain.Owner() | |
| 88 | + | assert.Equal(t, "owner", o.Label()) | |
| 89 | + | assert.True(t, o.IsOwner()) | |
| 90 | + | assert.False(t, domain.Root().IsOwner()) | |
| 91 | + | assert.True(t, o.IsRoot(), "the owner has the rights of root") | |
| 92 | + | assert.True(t, o.HasScope(domain.ScopeMRMerge)) | |
| 93 | + | assert.True(t, o.Allows(domain.ScopeRepoRead, mustName(t, "any"))) | |
| 94 | + | assert.False(t, o.IsAnonymous()) | |
| 95 | + | assert.Empty(t, o.TokenID()) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestHasOwnerRights(t *testing.T) { | |
| 99 | + | a := mustName(t, "a") | |
| 100 | + | all := domain.AllScopes() | |
| 101 | + | assert.True(t, domain.Root().HasOwnerRights()) | |
| 102 | + | assert.True(t, domain.Owner().HasOwnerRights()) | |
| 103 | + | assert.False(t, domain.Anonymous().HasOwnerRights()) | |
| 104 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights()) | |
| 105 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | |
| 106 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | |
| 107 | + | } | |
| @@ -82,3 +82,26 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||||
| 82 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| 83 | return n | 83 | return n | ||
| 84 | } | 84 | } | ||
| 85 | + | ||||
| 86 | + | func TestOwnerPrincipal(t *testing.T) { | |||
| 87 | + | o := domain.Owner() | |||
| 88 | + | assert.Equal(t, "owner", o.Label()) | |||
| 89 | + | assert.True(t, o.IsOwner()) | |||
| 90 | + | assert.False(t, domain.Root().IsOwner()) | |||
| 91 | + | assert.True(t, o.IsRoot(), "the owner has the rights of root") | |||
| 92 | + | assert.True(t, o.HasScope(domain.ScopeMRMerge)) | |||
| 93 | + | assert.True(t, o.Allows(domain.ScopeRepoRead, mustName(t, "any"))) | |||
| 94 | + | assert.False(t, o.IsAnonymous()) | |||
| 95 | + | assert.Empty(t, o.TokenID()) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHasOwnerRights(t *testing.T) { | |||
| 99 | + | a := mustName(t, "a") | |||
| 100 | + | all := domain.AllScopes() | |||
| 101 | + | assert.True(t, domain.Root().HasOwnerRights()) | |||
| 102 | + | assert.True(t, domain.Owner().HasOwnerRights()) | |||
| 103 | + | assert.False(t, domain.Anonymous().HasOwnerRights()) | |||
| 104 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights()) | |||
| 105 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | |||
| 106 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | |||
| 107 | + | } | |||
A internal/ports/mocks/SessionStore.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewSessionStore creates a new instance of SessionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewSessionStore(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *SessionStore { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &SessionStore{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SessionStore is an autogenerated mock type for the SessionStore type | |
| 37 | + | type SessionStore struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type SessionStore_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *SessionStore) EXPECT() *SessionStore_Expecter { | |
| 46 | + | return &SessionStore_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // BumpEpoch provides a mock function for the type SessionStore | |
| 50 | + | func (_mock *SessionStore) BumpEpoch(ctx context.Context) (uint64, error) { | |
| 51 | + | ret := _mock.Called(ctx) | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for BumpEpoch") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 uint64 | |
| 58 | + | var r1 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { | |
| 60 | + | return returnFunc(ctx) | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { | |
| 63 | + | r0 = returnFunc(ctx) | |
| 64 | + | } else { | |
| 65 | + | r0 = ret.Get(0).(uint64) | |
| 66 | + | } | |
| 67 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 68 | + | r1 = returnFunc(ctx) | |
| 69 | + | } else { | |
| 70 | + | r1 = ret.Error(1) | |
| 71 | + | } | |
| 72 | + | return r0, r1 | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // SessionStore_BumpEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BumpEpoch' | |
| 76 | + | type SessionStore_BumpEpoch_Call struct { | |
| 77 | + | *mock.Call | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // BumpEpoch is a helper method to define mock.On call | |
| 81 | + | // - ctx context.Context | |
| 82 | + | func (_e *SessionStore_Expecter) BumpEpoch(ctx any) *SessionStore_BumpEpoch_Call { | |
| 83 | + | return &SessionStore_BumpEpoch_Call{Call: _e.mock.On("BumpEpoch", ctx)} | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (_c *SessionStore_BumpEpoch_Call) Run(run func(ctx context.Context)) *SessionStore_BumpEpoch_Call { | |
| 87 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 88 | + | var arg0 context.Context | |
| 89 | + | if args[0] != nil { | |
| 90 | + | arg0 = args[0].(context.Context) | |
| 91 | + | } | |
| 92 | + | run( | |
| 93 | + | arg0, | |
| 94 | + | ) | |
| 95 | + | }) | |
| 96 | + | return _c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *SessionStore_BumpEpoch_Call) Return(v uint64, err error) *SessionStore_BumpEpoch_Call { | |
| 100 | + | _c.Call.Return(v, err) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (_c *SessionStore_BumpEpoch_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *SessionStore_BumpEpoch_Call { | |
| 105 | + | _c.Call.Return(run) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Epoch provides a mock function for the type SessionStore | |
| 110 | + | func (_mock *SessionStore) Epoch(ctx context.Context) (uint64, error) { | |
| 111 | + | ret := _mock.Called(ctx) | |
| 112 | + | ||
| 113 | + | if len(ret) == 0 { | |
| 114 | + | panic("no return value specified for Epoch") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | var r0 uint64 | |
| 118 | + | var r1 error | |
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { | |
| 120 | + | return returnFunc(ctx) | |
| 121 | + | } | |
| 122 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { | |
| 123 | + | r0 = returnFunc(ctx) | |
| 124 | + | } else { | |
| 125 | + | r0 = ret.Get(0).(uint64) | |
| 126 | + | } | |
| 127 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 128 | + | r1 = returnFunc(ctx) | |
| 129 | + | } else { | |
| 130 | + | r1 = ret.Error(1) | |
| 131 | + | } | |
| 132 | + | return r0, r1 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // SessionStore_Epoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Epoch' | |
| 136 | + | type SessionStore_Epoch_Call struct { | |
| 137 | + | *mock.Call | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Epoch is a helper method to define mock.On call | |
| 141 | + | // - ctx context.Context | |
| 142 | + | func (_e *SessionStore_Expecter) Epoch(ctx any) *SessionStore_Epoch_Call { | |
| 143 | + | return &SessionStore_Epoch_Call{Call: _e.mock.On("Epoch", ctx)} | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (_c *SessionStore_Epoch_Call) Run(run func(ctx context.Context)) *SessionStore_Epoch_Call { | |
| 147 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 148 | + | var arg0 context.Context | |
| 149 | + | if args[0] != nil { | |
| 150 | + | arg0 = args[0].(context.Context) | |
| 151 | + | } | |
| 152 | + | run( | |
| 153 | + | arg0, | |
| 154 | + | ) | |
| 155 | + | }) | |
| 156 | + | return _c | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (_c *SessionStore_Epoch_Call) Return(v uint64, err error) *SessionStore_Epoch_Call { | |
| 160 | + | _c.Call.Return(v, err) | |
| 161 | + | return _c | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (_c *SessionStore_Epoch_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *SessionStore_Epoch_Call { | |
| 165 | + | _c.Call.Return(run) | |
| 166 | + | return _c | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // Key provides a mock function for the type SessionStore | |
| 170 | + | func (_mock *SessionStore) Key(ctx context.Context) ([]byte, error) { | |
| 171 | + | ret := _mock.Called(ctx) | |
| 172 | + | ||
| 173 | + | if len(ret) == 0 { | |
| 174 | + | panic("no return value specified for Key") | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | var r0 []byte | |
| 178 | + | var r1 error | |
| 179 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok { | |
| 180 | + | return returnFunc(ctx) | |
| 181 | + | } | |
| 182 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok { | |
| 183 | + | r0 = returnFunc(ctx) | |
| 184 | + | } else { | |
| 185 | + | if ret.Get(0) != nil { | |
| 186 | + | r0 = ret.Get(0).([]byte) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 190 | + | r1 = returnFunc(ctx) | |
| 191 | + | } else { | |
| 192 | + | r1 = ret.Error(1) | |
| 193 | + | } | |
| 194 | + | return r0, r1 | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // SessionStore_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key' | |
| 198 | + | type SessionStore_Key_Call struct { | |
| 199 | + | *mock.Call | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Key is a helper method to define mock.On call | |
| 203 | + | // - ctx context.Context | |
| 204 | + | func (_e *SessionStore_Expecter) Key(ctx any) *SessionStore_Key_Call { | |
| 205 | + | return &SessionStore_Key_Call{Call: _e.mock.On("Key", ctx)} | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *SessionStore_Key_Call) Run(run func(ctx context.Context)) *SessionStore_Key_Call { | |
| 209 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 210 | + | var arg0 context.Context | |
| 211 | + | if args[0] != nil { | |
| 212 | + | arg0 = args[0].(context.Context) | |
| 213 | + | } | |
| 214 | + | run( | |
| 215 | + | arg0, | |
| 216 | + | ) | |
| 217 | + | }) | |
| 218 | + | return _c | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (_c *SessionStore_Key_Call) Return(bytes []byte, err error) *SessionStore_Key_Call { | |
| 222 | + | _c.Call.Return(bytes, err) | |
| 223 | + | return _c | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (_c *SessionStore_Key_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *SessionStore_Key_Call { | |
| 227 | + | _c.Call.Return(run) | |
| 228 | + | return _c | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewSessionStore creates a new instance of SessionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewSessionStore(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *SessionStore { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &SessionStore{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // SessionStore is an autogenerated mock type for the SessionStore type | |||
| 37 | + | type SessionStore struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type SessionStore_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *SessionStore) EXPECT() *SessionStore_Expecter { | |||
| 46 | + | return &SessionStore_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // BumpEpoch provides a mock function for the type SessionStore | |||
| 50 | + | func (_mock *SessionStore) BumpEpoch(ctx context.Context) (uint64, error) { | |||
| 51 | + | ret := _mock.Called(ctx) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for BumpEpoch") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 uint64 | |||
| 58 | + | var r1 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { | |||
| 60 | + | return returnFunc(ctx) | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { | |||
| 63 | + | r0 = returnFunc(ctx) | |||
| 64 | + | } else { | |||
| 65 | + | r0 = ret.Get(0).(uint64) | |||
| 66 | + | } | |||
| 67 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 68 | + | r1 = returnFunc(ctx) | |||
| 69 | + | } else { | |||
| 70 | + | r1 = ret.Error(1) | |||
| 71 | + | } | |||
| 72 | + | return r0, r1 | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // SessionStore_BumpEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BumpEpoch' | |||
| 76 | + | type SessionStore_BumpEpoch_Call struct { | |||
| 77 | + | *mock.Call | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // BumpEpoch is a helper method to define mock.On call | |||
| 81 | + | // - ctx context.Context | |||
| 82 | + | func (_e *SessionStore_Expecter) BumpEpoch(ctx any) *SessionStore_BumpEpoch_Call { | |||
| 83 | + | return &SessionStore_BumpEpoch_Call{Call: _e.mock.On("BumpEpoch", ctx)} | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (_c *SessionStore_BumpEpoch_Call) Run(run func(ctx context.Context)) *SessionStore_BumpEpoch_Call { | |||
| 87 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 88 | + | var arg0 context.Context | |||
| 89 | + | if args[0] != nil { | |||
| 90 | + | arg0 = args[0].(context.Context) | |||
| 91 | + | } | |||
| 92 | + | run( | |||
| 93 | + | arg0, | |||
| 94 | + | ) | |||
| 95 | + | }) | |||
| 96 | + | return _c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (_c *SessionStore_BumpEpoch_Call) Return(v uint64, err error) *SessionStore_BumpEpoch_Call { | |||
| 100 | + | _c.Call.Return(v, err) | |||
| 101 | + | return _c | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (_c *SessionStore_BumpEpoch_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *SessionStore_BumpEpoch_Call { | |||
| 105 | + | _c.Call.Return(run) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Epoch provides a mock function for the type SessionStore | |||
| 110 | + | func (_mock *SessionStore) Epoch(ctx context.Context) (uint64, error) { | |||
| 111 | + | ret := _mock.Called(ctx) | |||
| 112 | + | ||||
| 113 | + | if len(ret) == 0 { | |||
| 114 | + | panic("no return value specified for Epoch") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | var r0 uint64 | |||
| 118 | + | var r1 error | |||
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { | |||
| 120 | + | return returnFunc(ctx) | |||
| 121 | + | } | |||
| 122 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) uint64); ok { | |||
| 123 | + | r0 = returnFunc(ctx) | |||
| 124 | + | } else { | |||
| 125 | + | r0 = ret.Get(0).(uint64) | |||
| 126 | + | } | |||
| 127 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 128 | + | r1 = returnFunc(ctx) | |||
| 129 | + | } else { | |||
| 130 | + | r1 = ret.Error(1) | |||
| 131 | + | } | |||
| 132 | + | return r0, r1 | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // SessionStore_Epoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Epoch' | |||
| 136 | + | type SessionStore_Epoch_Call struct { | |||
| 137 | + | *mock.Call | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Epoch is a helper method to define mock.On call | |||
| 141 | + | // - ctx context.Context | |||
| 142 | + | func (_e *SessionStore_Expecter) Epoch(ctx any) *SessionStore_Epoch_Call { | |||
| 143 | + | return &SessionStore_Epoch_Call{Call: _e.mock.On("Epoch", ctx)} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (_c *SessionStore_Epoch_Call) Run(run func(ctx context.Context)) *SessionStore_Epoch_Call { | |||
| 147 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 148 | + | var arg0 context.Context | |||
| 149 | + | if args[0] != nil { | |||
| 150 | + | arg0 = args[0].(context.Context) | |||
| 151 | + | } | |||
| 152 | + | run( | |||
| 153 | + | arg0, | |||
| 154 | + | ) | |||
| 155 | + | }) | |||
| 156 | + | return _c | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (_c *SessionStore_Epoch_Call) Return(v uint64, err error) *SessionStore_Epoch_Call { | |||
| 160 | + | _c.Call.Return(v, err) | |||
| 161 | + | return _c | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (_c *SessionStore_Epoch_Call) RunAndReturn(run func(ctx context.Context) (uint64, error)) *SessionStore_Epoch_Call { | |||
| 165 | + | _c.Call.Return(run) | |||
| 166 | + | return _c | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Key provides a mock function for the type SessionStore | |||
| 170 | + | func (_mock *SessionStore) Key(ctx context.Context) ([]byte, error) { | |||
| 171 | + | ret := _mock.Called(ctx) | |||
| 172 | + | ||||
| 173 | + | if len(ret) == 0 { | |||
| 174 | + | panic("no return value specified for Key") | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | var r0 []byte | |||
| 178 | + | var r1 error | |||
| 179 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok { | |||
| 180 | + | return returnFunc(ctx) | |||
| 181 | + | } | |||
| 182 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok { | |||
| 183 | + | r0 = returnFunc(ctx) | |||
| 184 | + | } else { | |||
| 185 | + | if ret.Get(0) != nil { | |||
| 186 | + | r0 = ret.Get(0).([]byte) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 190 | + | r1 = returnFunc(ctx) | |||
| 191 | + | } else { | |||
| 192 | + | r1 = ret.Error(1) | |||
| 193 | + | } | |||
| 194 | + | return r0, r1 | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // SessionStore_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key' | |||
| 198 | + | type SessionStore_Key_Call struct { | |||
| 199 | + | *mock.Call | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Key is a helper method to define mock.On call | |||
| 203 | + | // - ctx context.Context | |||
| 204 | + | func (_e *SessionStore_Expecter) Key(ctx any) *SessionStore_Key_Call { | |||
| 205 | + | return &SessionStore_Key_Call{Call: _e.mock.On("Key", ctx)} | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (_c *SessionStore_Key_Call) Run(run func(ctx context.Context)) *SessionStore_Key_Call { | |||
| 209 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 210 | + | var arg0 context.Context | |||
| 211 | + | if args[0] != nil { | |||
| 212 | + | arg0 = args[0].(context.Context) | |||
| 213 | + | } | |||
| 214 | + | run( | |||
| 215 | + | arg0, | |||
| 216 | + | ) | |||
| 217 | + | }) | |||
| 218 | + | return _c | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (_c *SessionStore_Key_Call) Return(bytes []byte, err error) *SessionStore_Key_Call { | |||
| 222 | + | _c.Call.Return(bytes, err) | |||
| 223 | + | return _c | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (_c *SessionStore_Key_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *SessionStore_Key_Call { | |||
| 227 | + | _c.Call.Return(run) | |||
| 228 | + | return _c | |||
| 229 | + | } | |||
M internal/ports/ports.go +14 -0
| @@ -265,6 +265,20 @@ type CommitSigner interface { | |||
| 265 | 265 | Sign(payload []byte) (string, error) | |
| 266 | 266 | } | |
| 267 | 267 | ||
| 268 | + | // SessionStore keeps what the browser sessions of the owner (ADR 0018) need | |
| 269 | + | // to survive a restart: the key that signs them and the epoch that voids them. | |
| 270 | + | type SessionStore interface { | |
| 271 | + | // Key returns the 32-byte secret that signs the session cookies. The first | |
| 272 | + | // call on an empty store generates and stores it; a restart keeps it. | |
| 273 | + | Key(ctx context.Context) ([]byte, error) | |
| 274 | + | // Epoch returns the current epoch, 0 until the first BumpEpoch. A session | |
| 275 | + | // is valid only while its epoch is the current one. | |
| 276 | + | Epoch(ctx context.Context) (uint64, error) | |
| 277 | + | // BumpEpoch increments the epoch, which ends every session, and returns | |
| 278 | + | // the new value. | |
| 279 | + | BumpEpoch(ctx context.Context) (uint64, error) | |
| 280 | + | } | |
| 281 | + | ||
| 268 | 282 | // EventBus fans events out to in-process subscribers. | |
| 269 | 283 | type EventBus interface { | |
| 270 | 284 | // Publish hands the event to every subscriber without blocking. A | |
| @@ -265,6 +265,20 @@ type CommitSigner interface { | |||||
| 265 | Sign(payload []byte) (string, error) | 265 | Sign(payload []byte) (string, error) | ||
| 266 | } | 266 | } | ||
| 267 | 267 | ||||
| 268 | + | // SessionStore keeps what the browser sessions of the owner (ADR 0018) need | |||
| 269 | + | // to survive a restart: the key that signs them and the epoch that voids them. | |||
| 270 | + | type SessionStore interface { | |||
| 271 | + | // Key returns the 32-byte secret that signs the session cookies. The first | |||
| 272 | + | // call on an empty store generates and stores it; a restart keeps it. | |||
| 273 | + | Key(ctx context.Context) ([]byte, error) | |||
| 274 | + | // Epoch returns the current epoch, 0 until the first BumpEpoch. A session | |||
| 275 | + | // is valid only while its epoch is the current one. | |||
| 276 | + | Epoch(ctx context.Context) (uint64, error) | |||
| 277 | + | // BumpEpoch increments the epoch, which ends every session, and returns | |||
| 278 | + | // the new value. | |||
| 279 | + | BumpEpoch(ctx context.Context) (uint64, error) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 268 | // EventBus fans events out to in-process subscribers. | 282 | // EventBus fans events out to in-process subscribers. | ||
| 269 | type EventBus interface { | 283 | type EventBus interface { | ||
| 270 | // Publish hands the event to every subscriber without blocking. A | 284 | // Publish hands the event to every subscriber without blocking. A | ||
A proto/ermite/storage/session.proto +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of the web sessions (ADR 0018). | |
| 4 | + | ||
| 5 | + | syntax = "proto3"; | |
| 6 | + | ||
| 7 | + | package ermite.storage; | |
| 8 | + | ||
| 9 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| 10 | + | ||
| 11 | + | // SessionKey is stored under the key "session/key": the HS256 secret that | |
| 12 | + | // signs the session cookies, generated on first start. Rotating it signs | |
| 13 | + | // everyone out. | |
| 14 | + | message SessionKey { | |
| 15 | + | // 32 random bytes. | |
| 16 | + | bytes key = 1; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // SessionEpoch is stored under the key "session/epoch". A session is valid | |
| 20 | + | // only while its epoch claim equals it; "sign out everywhere" increments it. | |
| 21 | + | // Absent means 0. | |
| 22 | + | message SessionEpoch { | |
| 23 | + | uint64 epoch = 1; | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Internal, unversioned storage schema of the web sessions (ADR 0018). | |||
| 4 | + | ||||
| 5 | + | syntax = "proto3"; | |||
| 6 | + | ||||
| 7 | + | package ermite.storage; | |||
| 8 | + | ||||
| 9 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |||
| 10 | + | ||||
| 11 | + | // SessionKey is stored under the key "session/key": the HS256 secret that | |||
| 12 | + | // signs the session cookies, generated on first start. Rotating it signs | |||
| 13 | + | // everyone out. | |||
| 14 | + | message SessionKey { | |||
| 15 | + | // 32 random bytes. | |||
| 16 | + | bytes key = 1; | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // SessionEpoch is stored under the key "session/epoch". A session is valid | |||
| 20 | + | // only while its epoch claim equals it; "sign out everywhere" increments it. | |||
| 21 | + | // Absent means 0. | |||
| 22 | + | message SessionEpoch { | |||
| 23 | + | uint64 epoch = 1; | |||
| 24 | + | } | |||