detached c1f3cc9

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1223 -7
M .mockery.yml +1 -0
@@ -12,6 +12,7 @@ packages:
1212 GitRepos:
1313 Clock:
1414 TokenStore:
15+ SessionStore:
1516 GitBackend:
1617 EventBus:
1718 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
@@ -0,0 +1,181 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of the web sessions (ADR 0018).
4+
5+// Code generated by protoc-gen-go. DO NOT EDIT.
6+// versions:
7+// protoc-gen-go v1.36.12
8+// protoc (unknown)
9+// source: ermite/storage/session.proto
10+
11+package storagepb
12+
13+import (
14+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
15+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ reflect "reflect"
17+ sync "sync"
18+ unsafe "unsafe"
19+)
20+
21+const (
22+ // Verify that this generated code is sufficiently up-to-date.
23+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
24+ // Verify that runtime/protoimpl is sufficiently up-to-date.
25+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
26+)
27+
28+// SessionKey is stored under the key "session/key": the HS256 secret that
29+// signs the session cookies, generated on first start. Rotating it signs
30+// everyone out.
31+type SessionKey struct {
32+ state protoimpl.MessageState `protogen:"open.v1"`
33+ // 32 random bytes.
34+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
35+ unknownFields protoimpl.UnknownFields
36+ sizeCache protoimpl.SizeCache
37+}
38+
39+func (x *SessionKey) Reset() {
40+ *x = SessionKey{}
41+ mi := &file_ermite_storage_session_proto_msgTypes[0]
42+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
43+ ms.StoreMessageInfo(mi)
44+}
45+
46+func (x *SessionKey) String() string {
47+ return protoimpl.X.MessageStringOf(x)
48+}
49+
50+func (*SessionKey) ProtoMessage() {}
51+
52+func (x *SessionKey) ProtoReflect() protoreflect.Message {
53+ mi := &file_ermite_storage_session_proto_msgTypes[0]
54+ if x != nil {
55+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
56+ if ms.LoadMessageInfo() == nil {
57+ ms.StoreMessageInfo(mi)
58+ }
59+ return ms
60+ }
61+ return mi.MessageOf(x)
62+}
63+
64+// Deprecated: Use SessionKey.ProtoReflect.Descriptor instead.
65+func (*SessionKey) Descriptor() ([]byte, []int) {
66+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{0}
67+}
68+
69+func (x *SessionKey) GetKey() []byte {
70+ if x != nil {
71+ return x.Key
72+ }
73+ return nil
74+}
75+
76+// SessionEpoch is stored under the key "session/epoch". A session is valid
77+// only while its epoch claim equals it; "sign out everywhere" increments it.
78+// Absent means 0.
79+type SessionEpoch struct {
80+ state protoimpl.MessageState `protogen:"open.v1"`
81+ Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"`
82+ unknownFields protoimpl.UnknownFields
83+ sizeCache protoimpl.SizeCache
84+}
85+
86+func (x *SessionEpoch) Reset() {
87+ *x = SessionEpoch{}
88+ mi := &file_ermite_storage_session_proto_msgTypes[1]
89+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
90+ ms.StoreMessageInfo(mi)
91+}
92+
93+func (x *SessionEpoch) String() string {
94+ return protoimpl.X.MessageStringOf(x)
95+}
96+
97+func (*SessionEpoch) ProtoMessage() {}
98+
99+func (x *SessionEpoch) ProtoReflect() protoreflect.Message {
100+ mi := &file_ermite_storage_session_proto_msgTypes[1]
101+ if x != nil {
102+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
103+ if ms.LoadMessageInfo() == nil {
104+ ms.StoreMessageInfo(mi)
105+ }
106+ return ms
107+ }
108+ return mi.MessageOf(x)
109+}
110+
111+// Deprecated: Use SessionEpoch.ProtoReflect.Descriptor instead.
112+func (*SessionEpoch) Descriptor() ([]byte, []int) {
113+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{1}
114+}
115+
116+func (x *SessionEpoch) GetEpoch() uint64 {
117+ if x != nil {
118+ return x.Epoch
119+ }
120+ return 0
121+}
122+
123+var File_ermite_storage_session_proto protoreflect.FileDescriptor
124+
125+const file_ermite_storage_session_proto_rawDesc = "" +
126+ "\n" +
127+ "\x1cermite/storage/session.proto\x12\x0eermite.storage\"\x1e\n" +
128+ "\n" +
129+ "SessionKey\x12\x10\n" +
130+ "\x03key\x18\x01 \x01(\fR\x03key\"$\n" +
131+ "\fSessionEpoch\x12\x14\n" +
132+ "\x05epoch\x18\x01 \x01(\x04R\x05epochB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
133+
134+var (
135+ file_ermite_storage_session_proto_rawDescOnce sync.Once
136+ file_ermite_storage_session_proto_rawDescData []byte
137+)
138+
139+func file_ermite_storage_session_proto_rawDescGZIP() []byte {
140+ file_ermite_storage_session_proto_rawDescOnce.Do(func() {
141+ file_ermite_storage_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)))
142+ })
143+ return file_ermite_storage_session_proto_rawDescData
144+}
145+
146+var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
147+var file_ermite_storage_session_proto_goTypes = []any{
148+ (*SessionKey)(nil), // 0: ermite.storage.SessionKey
149+ (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch
150+}
151+var file_ermite_storage_session_proto_depIdxs = []int32{
152+ 0, // [0:0] is the sub-list for method output_type
153+ 0, // [0:0] is the sub-list for method input_type
154+ 0, // [0:0] is the sub-list for extension type_name
155+ 0, // [0:0] is the sub-list for extension extendee
156+ 0, // [0:0] is the sub-list for field type_name
157+}
158+
159+func init() { file_ermite_storage_session_proto_init() }
160+func file_ermite_storage_session_proto_init() {
161+ if File_ermite_storage_session_proto != nil {
162+ return
163+ }
164+ type x struct{}
165+ out := protoimpl.TypeBuilder{
166+ File: protoimpl.DescBuilder{
167+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
168+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)),
169+ NumEnums: 0,
170+ NumMessages: 2,
171+ NumExtensions: 0,
172+ NumServices: 0,
173+ },
174+ GoTypes: file_ermite_storage_session_proto_goTypes,
175+ DependencyIndexes: file_ermite_storage_session_proto_depIdxs,
176+ MessageInfos: file_ermite_storage_session_proto_msgTypes,
177+ }.Build()
178+ File_ermite_storage_session_proto = out.File
179+ file_ermite_storage_session_proto_goTypes = nil
180+ file_ermite_storage_session_proto_depIdxs = nil
181+}
@@ -0,0 +1,181 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of the web sessions (ADR 0018).
4+
5+// Code generated by protoc-gen-go. DO NOT EDIT.
6+// versions:
7+// protoc-gen-go v1.36.12
8+// protoc (unknown)
9+// source: ermite/storage/session.proto
10+
11+package storagepb
12+
13+import (
14+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
15+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ reflect "reflect"
17+ sync "sync"
18+ unsafe "unsafe"
19+)
20+
21+const (
22+ // Verify that this generated code is sufficiently up-to-date.
23+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
24+ // Verify that runtime/protoimpl is sufficiently up-to-date.
25+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
26+)
27+
28+// SessionKey is stored under the key "session/key": the HS256 secret that
29+// signs the session cookies, generated on first start. Rotating it signs
30+// everyone out.
31+type SessionKey struct {
32+ state protoimpl.MessageState `protogen:"open.v1"`
33+ // 32 random bytes.
34+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
35+ unknownFields protoimpl.UnknownFields
36+ sizeCache protoimpl.SizeCache
37+}
38+
39+func (x *SessionKey) Reset() {
40+ *x = SessionKey{}
41+ mi := &file_ermite_storage_session_proto_msgTypes[0]
42+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
43+ ms.StoreMessageInfo(mi)
44+}
45+
46+func (x *SessionKey) String() string {
47+ return protoimpl.X.MessageStringOf(x)
48+}
49+
50+func (*SessionKey) ProtoMessage() {}
51+
52+func (x *SessionKey) ProtoReflect() protoreflect.Message {
53+ mi := &file_ermite_storage_session_proto_msgTypes[0]
54+ if x != nil {
55+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
56+ if ms.LoadMessageInfo() == nil {
57+ ms.StoreMessageInfo(mi)
58+ }
59+ return ms
60+ }
61+ return mi.MessageOf(x)
62+}
63+
64+// Deprecated: Use SessionKey.ProtoReflect.Descriptor instead.
65+func (*SessionKey) Descriptor() ([]byte, []int) {
66+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{0}
67+}
68+
69+func (x *SessionKey) GetKey() []byte {
70+ if x != nil {
71+ return x.Key
72+ }
73+ return nil
74+}
75+
76+// SessionEpoch is stored under the key "session/epoch". A session is valid
77+// only while its epoch claim equals it; "sign out everywhere" increments it.
78+// Absent means 0.
79+type SessionEpoch struct {
80+ state protoimpl.MessageState `protogen:"open.v1"`
81+ Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"`
82+ unknownFields protoimpl.UnknownFields
83+ sizeCache protoimpl.SizeCache
84+}
85+
86+func (x *SessionEpoch) Reset() {
87+ *x = SessionEpoch{}
88+ mi := &file_ermite_storage_session_proto_msgTypes[1]
89+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
90+ ms.StoreMessageInfo(mi)
91+}
92+
93+func (x *SessionEpoch) String() string {
94+ return protoimpl.X.MessageStringOf(x)
95+}
96+
97+func (*SessionEpoch) ProtoMessage() {}
98+
99+func (x *SessionEpoch) ProtoReflect() protoreflect.Message {
100+ mi := &file_ermite_storage_session_proto_msgTypes[1]
101+ if x != nil {
102+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
103+ if ms.LoadMessageInfo() == nil {
104+ ms.StoreMessageInfo(mi)
105+ }
106+ return ms
107+ }
108+ return mi.MessageOf(x)
109+}
110+
111+// Deprecated: Use SessionEpoch.ProtoReflect.Descriptor instead.
112+func (*SessionEpoch) Descriptor() ([]byte, []int) {
113+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{1}
114+}
115+
116+func (x *SessionEpoch) GetEpoch() uint64 {
117+ if x != nil {
118+ return x.Epoch
119+ }
120+ return 0
121+}
122+
123+var File_ermite_storage_session_proto protoreflect.FileDescriptor
124+
125+const file_ermite_storage_session_proto_rawDesc = "" +
126+ "\n" +
127+ "\x1cermite/storage/session.proto\x12\x0eermite.storage\"\x1e\n" +
128+ "\n" +
129+ "SessionKey\x12\x10\n" +
130+ "\x03key\x18\x01 \x01(\fR\x03key\"$\n" +
131+ "\fSessionEpoch\x12\x14\n" +
132+ "\x05epoch\x18\x01 \x01(\x04R\x05epochB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
133+
134+var (
135+ file_ermite_storage_session_proto_rawDescOnce sync.Once
136+ file_ermite_storage_session_proto_rawDescData []byte
137+)
138+
139+func file_ermite_storage_session_proto_rawDescGZIP() []byte {
140+ file_ermite_storage_session_proto_rawDescOnce.Do(func() {
141+ file_ermite_storage_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)))
142+ })
143+ return file_ermite_storage_session_proto_rawDescData
144+}
145+
146+var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
147+var file_ermite_storage_session_proto_goTypes = []any{
148+ (*SessionKey)(nil), // 0: ermite.storage.SessionKey
149+ (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch
150+}
151+var file_ermite_storage_session_proto_depIdxs = []int32{
152+ 0, // [0:0] is the sub-list for method output_type
153+ 0, // [0:0] is the sub-list for method input_type
154+ 0, // [0:0] is the sub-list for extension type_name
155+ 0, // [0:0] is the sub-list for extension extendee
156+ 0, // [0:0] is the sub-list for field type_name
157+}
158+
159+func init() { file_ermite_storage_session_proto_init() }
160+func file_ermite_storage_session_proto_init() {
161+ if File_ermite_storage_session_proto != nil {
162+ return
163+ }
164+ type x struct{}
165+ out := protoimpl.TypeBuilder{
166+ File: protoimpl.DescBuilder{
167+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
168+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)),
169+ NumEnums: 0,
170+ NumMessages: 2,
171+ NumExtensions: 0,
172+ NumServices: 0,
173+ },
174+ GoTypes: file_ermite_storage_session_proto_goTypes,
175+ DependencyIndexes: file_ermite_storage_session_proto_depIdxs,
176+ MessageInfos: file_ermite_storage_session_proto_msgTypes,
177+ }.Build()
178+ File_ermite_storage_session_proto = out.File
179+ file_ermite_storage_session_proto_goTypes = nil
180+ file_ermite_storage_session_proto_depIdxs = nil
181+}
M go.mod +1 -0
@@ -18,6 +18,7 @@ require (
1818 github.com/a-h/templ v0.3.1020
1919 github.com/alecthomas/chroma/v2 v2.27.0
2020 github.com/bnema/zerowrap v1.4.1
21+ github.com/golang-jwt/jwt/v5 v5.3.1
2122 github.com/spf13/cobra v1.10.2
2223 github.com/stretchr/testify v1.12.1
2324 github.com/yuin/goldmark v1.8.6
@@ -18,6 +18,7 @@ require (
18 github.com/a-h/templ v0.3.102018 github.com/a-h/templ v0.3.1020
19 github.com/alecthomas/chroma/v2 v2.27.019 github.com/alecthomas/chroma/v2 v2.27.0
20 github.com/bnema/zerowrap v1.4.120 github.com/bnema/zerowrap v1.4.1
21+ github.com/golang-jwt/jwt/v5 v5.3.1
21 github.com/spf13/cobra v1.10.222 github.com/spf13/cobra v1.10.2
22 github.com/stretchr/testify v1.12.123 github.com/stretchr/testify v1.12.1
23 github.com/yuin/goldmark v1.8.624 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 (
1515 kindAnonymous principalKind = iota
1616 kindRoot
1717 kindToken
18+ kindOwner
1819 )
1920
2021 // 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.
2224 type Principal struct {
2325 kind principalKind
2426 tokenID string
@@ -34,6 +36,11 @@ func Anonymous() Principal { return Principal{kind: kindAnonymous} }
3436 // Root returns the principal of the admin token holder.
3537 func Root() Principal { return Principal{kind: kindRoot} }
3638
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+
3744 // TokenPrincipal returns the principal of the holder of a stored token.
3845 func TokenPrincipal(t Token) Principal {
3946 return Principal{
@@ -47,19 +54,43 @@ func TokenPrincipal(t Token) Principal {
4754 }
4855
4956 // Label names the kind of principal for logs and messages: "anonymous",
50-// "root" or "token".
57+// "root", "owner" or "token".
5158 func (p Principal) Label() string {
5259 switch p.kind {
5360 case kindRoot:
5461 return "root"
62+ case kindOwner:
63+ return "owner"
5564 case kindToken:
5665 return "token"
5766 }
5867 return "anonymous"
5968 }
6069
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 }
6394
6495 // IsAnonymous reports whether p has no credentials.
6596 func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous }
@@ -77,11 +108,11 @@ func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
77108 // ExpiresAt returns the expiry of a stored token; zero means none.
78109 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
79110
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.
82113 func (p Principal) HasScope(s Scope) bool {
83114 switch p.kind {
84- case kindRoot:
115+ case kindRoot, kindOwner:
85116 return true
86117 case kindToken:
87118 return slices.Contains(p.scopes, s)
@@ -15,10 +15,12 @@ const (
15 kindAnonymous principalKind = iota15 kindAnonymous principalKind = iota
16 kindRoot16 kindRoot
17 kindToken17 kindToken
18+ kindOwner
18 )19 )
19 20
20 // Principal is the authenticated identity of a caller: nobody, the root21 // 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 principalKind25 kind principalKind
24 tokenID string26 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 holds111+// 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 true116 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 {
8282 require.NoError(t, err)
8383 return n
8484 }
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 n83 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 {
265265 Sign(payload []byte) (string, error)
266266 }
267267
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+
268282 // EventBus fans events out to in-process subscribers.
269283 type EventBus interface {
270284 // 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. A284 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close