detached a2a22bf

feat(usecase): add the owner passkey use cases

commit
a2a22bf1f9f568efcc6a1d284e7c5434d2e6deef
author
brice · · 2026-10-12 14:55 UTC
parent
3246333
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +1708 -0
A internal/ports/mocks/PasskeyService.go +179 -0
@@ -0,0 +1,179 @@
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+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPasskeyService creates a new instance of PasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPasskeyService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PasskeyService {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PasskeyService{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PasskeyService is an autogenerated mock type for the PasskeyService type
38+type PasskeyService struct {
39+ mock.Mock
40+}
41+
42+type PasskeyService_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PasskeyService) EXPECT() *PasskeyService_Expecter {
47+ return &PasskeyService_Expecter{mock: &_m.Mock}
48+}
49+
50+// List provides a mock function for the type PasskeyService
51+func (_mock *PasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for List")
56+ }
57+
58+ var r0 []domain.Passkey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
61+ return returnFunc(ctx, p)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
64+ r0 = returnFunc(ctx, p)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.Passkey)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
71+ r1 = returnFunc(ctx, p)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
79+type PasskeyService_List_Call struct {
80+ *mock.Call
81+}
82+
83+// List is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+func (_e *PasskeyService_Expecter) List(ctx any, p any) *PasskeyService_List_Call {
87+ return &PasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
88+}
89+
90+func (_c *PasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *PasskeyService_List_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *PasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyService_List_Call {
109+ _c.Call.Return(passkeys, err)
110+ return _c
111+}
112+
113+func (_c *PasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *PasskeyService_List_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// Remove provides a mock function for the type PasskeyService
119+func (_mock *PasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
120+ ret := _mock.Called(ctx, p, id)
121+
122+ if len(ret) == 0 {
123+ panic("no return value specified for Remove")
124+ }
125+
126+ var r0 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
128+ r0 = returnFunc(ctx, p, id)
129+ } else {
130+ r0 = ret.Error(0)
131+ }
132+ return r0
133+}
134+
135+// PasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
136+type PasskeyService_Remove_Call struct {
137+ *mock.Call
138+}
139+
140+// Remove is a helper method to define mock.On call
141+// - ctx context.Context
142+// - p domain.Principal
143+// - id []byte
144+func (_e *PasskeyService_Expecter) Remove(ctx any, p any, id any) *PasskeyService_Remove_Call {
145+ return &PasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
146+}
147+
148+func (_c *PasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *PasskeyService_Remove_Call {
149+ _c.Call.Run(func(args mock.Arguments) {
150+ var arg0 context.Context
151+ if args[0] != nil {
152+ arg0 = args[0].(context.Context)
153+ }
154+ var arg1 domain.Principal
155+ if args[1] != nil {
156+ arg1 = args[1].(domain.Principal)
157+ }
158+ var arg2 []byte
159+ if args[2] != nil {
160+ arg2 = args[2].([]byte)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ )
167+ })
168+ return _c
169+}
170+
171+func (_c *PasskeyService_Remove_Call) Return(err error) *PasskeyService_Remove_Call {
172+ _c.Call.Return(err)
173+ return _c
174+}
175+
176+func (_c *PasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *PasskeyService_Remove_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
@@ -0,0 +1,179 @@
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+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPasskeyService creates a new instance of PasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPasskeyService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PasskeyService {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PasskeyService{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PasskeyService is an autogenerated mock type for the PasskeyService type
38+type PasskeyService struct {
39+ mock.Mock
40+}
41+
42+type PasskeyService_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PasskeyService) EXPECT() *PasskeyService_Expecter {
47+ return &PasskeyService_Expecter{mock: &_m.Mock}
48+}
49+
50+// List provides a mock function for the type PasskeyService
51+func (_mock *PasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for List")
56+ }
57+
58+ var r0 []domain.Passkey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
61+ return returnFunc(ctx, p)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
64+ r0 = returnFunc(ctx, p)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.Passkey)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
71+ r1 = returnFunc(ctx, p)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
79+type PasskeyService_List_Call struct {
80+ *mock.Call
81+}
82+
83+// List is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+func (_e *PasskeyService_Expecter) List(ctx any, p any) *PasskeyService_List_Call {
87+ return &PasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
88+}
89+
90+func (_c *PasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *PasskeyService_List_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *PasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyService_List_Call {
109+ _c.Call.Return(passkeys, err)
110+ return _c
111+}
112+
113+func (_c *PasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *PasskeyService_List_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// Remove provides a mock function for the type PasskeyService
119+func (_mock *PasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
120+ ret := _mock.Called(ctx, p, id)
121+
122+ if len(ret) == 0 {
123+ panic("no return value specified for Remove")
124+ }
125+
126+ var r0 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
128+ r0 = returnFunc(ctx, p, id)
129+ } else {
130+ r0 = ret.Error(0)
131+ }
132+ return r0
133+}
134+
135+// PasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
136+type PasskeyService_Remove_Call struct {
137+ *mock.Call
138+}
139+
140+// Remove is a helper method to define mock.On call
141+// - ctx context.Context
142+// - p domain.Principal
143+// - id []byte
144+func (_e *PasskeyService_Expecter) Remove(ctx any, p any, id any) *PasskeyService_Remove_Call {
145+ return &PasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
146+}
147+
148+func (_c *PasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *PasskeyService_Remove_Call {
149+ _c.Call.Run(func(args mock.Arguments) {
150+ var arg0 context.Context
151+ if args[0] != nil {
152+ arg0 = args[0].(context.Context)
153+ }
154+ var arg1 domain.Principal
155+ if args[1] != nil {
156+ arg1 = args[1].(domain.Principal)
157+ }
158+ var arg2 []byte
159+ if args[2] != nil {
160+ arg2 = args[2].([]byte)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ )
167+ })
168+ return _c
169+}
170+
171+func (_c *PasskeyService_Remove_Call) Return(err error) *PasskeyService_Remove_Call {
172+ _c.Call.Return(err)
173+ return _c
174+}
175+
176+func (_c *PasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *PasskeyService_Remove_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
A internal/ports/mocks/WebPasskeyService.go +475 -0
@@ -0,0 +1,475 @@
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+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewWebPasskeyService creates a new instance of WebPasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewWebPasskeyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *WebPasskeyService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &WebPasskeyService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// WebPasskeyService is an autogenerated mock type for the WebPasskeyService type
39+type WebPasskeyService struct {
40+ mock.Mock
41+}
42+
43+type WebPasskeyService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter {
48+ return &WebPasskeyService_Expecter{mock: &_m.Mock}
49+}
50+
51+// BeginLogin provides a mock function for the type WebPasskeyService
52+func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte, error) {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for BeginLogin")
57+ }
58+
59+ var r0 string
60+ var r1 []byte
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
63+ return returnFunc(ctx)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
66+ r0 = returnFunc(ctx)
67+ } else {
68+ r0 = ret.Get(0).(string)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ if ret.Get(1) != nil {
74+ r1 = ret.Get(1).([]byte)
75+ }
76+ }
77+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
78+ r2 = returnFunc(ctx)
79+ } else {
80+ r2 = ret.Error(2)
81+ }
82+ return r0, r1, r2
83+}
84+
85+// WebPasskeyService_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin'
86+type WebPasskeyService_BeginLogin_Call struct {
87+ *mock.Call
88+}
89+
90+// BeginLogin is a helper method to define mock.On call
91+// - ctx context.Context
92+func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any) *WebPasskeyService_BeginLogin_Call {
93+ return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx)}
94+}
95+
96+func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context)) *WebPasskeyService_BeginLogin_Call {
97+ _c.Call.Run(func(args mock.Arguments) {
98+ var arg0 context.Context
99+ if args[0] != nil {
100+ arg0 = args[0].(context.Context)
101+ }
102+ run(
103+ arg0,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginLogin_Call {
110+ _c.Call.Return(ceremony, options, err)
111+ return _c
112+}
113+
114+func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// BeginRegistration provides a mock function for the type WebPasskeyService
120+func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
121+ ret := _mock.Called(ctx, p)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for BeginRegistration")
125+ }
126+
127+ var r0 string
128+ var r1 []byte
129+ var r2 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (string, []byte, error)); ok {
131+ return returnFunc(ctx, p)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) string); ok {
134+ r0 = returnFunc(ctx, p)
135+ } else {
136+ r0 = ret.Get(0).(string)
137+ }
138+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) []byte); ok {
139+ r1 = returnFunc(ctx, p)
140+ } else {
141+ if ret.Get(1) != nil {
142+ r1 = ret.Get(1).([]byte)
143+ }
144+ }
145+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal) error); ok {
146+ r2 = returnFunc(ctx, p)
147+ } else {
148+ r2 = ret.Error(2)
149+ }
150+ return r0, r1, r2
151+}
152+
153+// WebPasskeyService_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration'
154+type WebPasskeyService_BeginRegistration_Call struct {
155+ *mock.Call
156+}
157+
158+// BeginRegistration is a helper method to define mock.On call
159+// - ctx context.Context
160+// - p domain.Principal
161+func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any) *WebPasskeyService_BeginRegistration_Call {
162+ return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p)}
163+}
164+
165+func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_BeginRegistration_Call {
166+ _c.Call.Run(func(args mock.Arguments) {
167+ var arg0 context.Context
168+ if args[0] != nil {
169+ arg0 = args[0].(context.Context)
170+ }
171+ var arg1 domain.Principal
172+ if args[1] != nil {
173+ arg1 = args[1].(domain.Principal)
174+ }
175+ run(
176+ arg0,
177+ arg1,
178+ )
179+ })
180+ return _c
181+}
182+
183+func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginRegistration_Call {
184+ _c.Call.Return(ceremony, options, err)
185+ return _c
186+}
187+
188+func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
189+ _c.Call.Return(run)
190+ return _c
191+}
192+
193+// FinishLogin provides a mock function for the type WebPasskeyService
194+func (_mock *WebPasskeyService) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
195+ ret := _mock.Called(ctx, ceremony, response)
196+
197+ if len(ret) == 0 {
198+ panic("no return value specified for FinishLogin")
199+ }
200+
201+ var r0 string
202+ var r1 time.Duration
203+ var r2 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) (string, time.Duration, error)); ok {
205+ return returnFunc(ctx, ceremony, response)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) string); ok {
208+ r0 = returnFunc(ctx, ceremony, response)
209+ } else {
210+ r0 = ret.Get(0).(string)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte) time.Duration); ok {
213+ r1 = returnFunc(ctx, ceremony, response)
214+ } else {
215+ r1 = ret.Get(1).(time.Duration)
216+ }
217+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, []byte) error); ok {
218+ r2 = returnFunc(ctx, ceremony, response)
219+ } else {
220+ r2 = ret.Error(2)
221+ }
222+ return r0, r1, r2
223+}
224+
225+// WebPasskeyService_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin'
226+type WebPasskeyService_FinishLogin_Call struct {
227+ *mock.Call
228+}
229+
230+// FinishLogin is a helper method to define mock.On call
231+// - ctx context.Context
232+// - ceremony string
233+// - response []byte
234+func (_e *WebPasskeyService_Expecter) FinishLogin(ctx any, ceremony any, response any) *WebPasskeyService_FinishLogin_Call {
235+ return &WebPasskeyService_FinishLogin_Call{Call: _e.mock.On("FinishLogin", ctx, ceremony, response)}
236+}
237+
238+func (_c *WebPasskeyService_FinishLogin_Call) Run(run func(ctx context.Context, ceremony string, response []byte)) *WebPasskeyService_FinishLogin_Call {
239+ _c.Call.Run(func(args mock.Arguments) {
240+ var arg0 context.Context
241+ if args[0] != nil {
242+ arg0 = args[0].(context.Context)
243+ }
244+ var arg1 string
245+ if args[1] != nil {
246+ arg1 = args[1].(string)
247+ }
248+ var arg2 []byte
249+ if args[2] != nil {
250+ arg2 = args[2].([]byte)
251+ }
252+ run(
253+ arg0,
254+ arg1,
255+ arg2,
256+ )
257+ })
258+ return _c
259+}
260+
261+func (_c *WebPasskeyService_FinishLogin_Call) Return(token string, lifetime time.Duration, err error) *WebPasskeyService_FinishLogin_Call {
262+ _c.Call.Return(token, lifetime, err)
263+ return _c
264+}
265+
266+func (_c *WebPasskeyService_FinishLogin_Call) RunAndReturn(run func(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error)) *WebPasskeyService_FinishLogin_Call {
267+ _c.Call.Return(run)
268+ return _c
269+}
270+
271+// FinishRegistration provides a mock function for the type WebPasskeyService
272+func (_mock *WebPasskeyService) FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte) error {
273+ ret := _mock.Called(ctx, p, ceremony, name, response)
274+
275+ if len(ret) == 0 {
276+ panic("no return value specified for FinishRegistration")
277+ }
278+
279+ var r0 error
280+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) error); ok {
281+ r0 = returnFunc(ctx, p, ceremony, name, response)
282+ } else {
283+ r0 = ret.Error(0)
284+ }
285+ return r0
286+}
287+
288+// WebPasskeyService_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration'
289+type WebPasskeyService_FinishRegistration_Call struct {
290+ *mock.Call
291+}
292+
293+// FinishRegistration is a helper method to define mock.On call
294+// - ctx context.Context
295+// - p domain.Principal
296+// - ceremony string
297+// - name string
298+// - response []byte
299+func (_e *WebPasskeyService_Expecter) FinishRegistration(ctx any, p any, ceremony any, name any, response any) *WebPasskeyService_FinishRegistration_Call {
300+ return &WebPasskeyService_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, p, ceremony, name, response)}
301+}
302+
303+func (_c *WebPasskeyService_FinishRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte)) *WebPasskeyService_FinishRegistration_Call {
304+ _c.Call.Run(func(args mock.Arguments) {
305+ var arg0 context.Context
306+ if args[0] != nil {
307+ arg0 = args[0].(context.Context)
308+ }
309+ var arg1 domain.Principal
310+ if args[1] != nil {
311+ arg1 = args[1].(domain.Principal)
312+ }
313+ var arg2 string
314+ if args[2] != nil {
315+ arg2 = args[2].(string)
316+ }
317+ var arg3 string
318+ if args[3] != nil {
319+ arg3 = args[3].(string)
320+ }
321+ var arg4 []byte
322+ if args[4] != nil {
323+ arg4 = args[4].([]byte)
324+ }
325+ run(
326+ arg0,
327+ arg1,
328+ arg2,
329+ arg3,
330+ arg4,
331+ )
332+ })
333+ return _c
334+}
335+
336+func (_c *WebPasskeyService_FinishRegistration_Call) Return(err error) *WebPasskeyService_FinishRegistration_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *WebPasskeyService_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte) error) *WebPasskeyService_FinishRegistration_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// List provides a mock function for the type WebPasskeyService
347+func (_mock *WebPasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
348+ ret := _mock.Called(ctx, p)
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for List")
352+ }
353+
354+ var r0 []domain.Passkey
355+ var r1 error
356+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
357+ return returnFunc(ctx, p)
358+ }
359+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
360+ r0 = returnFunc(ctx, p)
361+ } else {
362+ if ret.Get(0) != nil {
363+ r0 = ret.Get(0).([]domain.Passkey)
364+ }
365+ }
366+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
367+ r1 = returnFunc(ctx, p)
368+ } else {
369+ r1 = ret.Error(1)
370+ }
371+ return r0, r1
372+}
373+
374+// WebPasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
375+type WebPasskeyService_List_Call struct {
376+ *mock.Call
377+}
378+
379+// List is a helper method to define mock.On call
380+// - ctx context.Context
381+// - p domain.Principal
382+func (_e *WebPasskeyService_Expecter) List(ctx any, p any) *WebPasskeyService_List_Call {
383+ return &WebPasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
384+}
385+
386+func (_c *WebPasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_List_Call {
387+ _c.Call.Run(func(args mock.Arguments) {
388+ var arg0 context.Context
389+ if args[0] != nil {
390+ arg0 = args[0].(context.Context)
391+ }
392+ var arg1 domain.Principal
393+ if args[1] != nil {
394+ arg1 = args[1].(domain.Principal)
395+ }
396+ run(
397+ arg0,
398+ arg1,
399+ )
400+ })
401+ return _c
402+}
403+
404+func (_c *WebPasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *WebPasskeyService_List_Call {
405+ _c.Call.Return(passkeys, err)
406+ return _c
407+}
408+
409+func (_c *WebPasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *WebPasskeyService_List_Call {
410+ _c.Call.Return(run)
411+ return _c
412+}
413+
414+// Remove provides a mock function for the type WebPasskeyService
415+func (_mock *WebPasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
416+ ret := _mock.Called(ctx, p, id)
417+
418+ if len(ret) == 0 {
419+ panic("no return value specified for Remove")
420+ }
421+
422+ var r0 error
423+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
424+ r0 = returnFunc(ctx, p, id)
425+ } else {
426+ r0 = ret.Error(0)
427+ }
428+ return r0
429+}
430+
431+// WebPasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
432+type WebPasskeyService_Remove_Call struct {
433+ *mock.Call
434+}
435+
436+// Remove is a helper method to define mock.On call
437+// - ctx context.Context
438+// - p domain.Principal
439+// - id []byte
440+func (_e *WebPasskeyService_Expecter) Remove(ctx any, p any, id any) *WebPasskeyService_Remove_Call {
441+ return &WebPasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
442+}
443+
444+func (_c *WebPasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *WebPasskeyService_Remove_Call {
445+ _c.Call.Run(func(args mock.Arguments) {
446+ var arg0 context.Context
447+ if args[0] != nil {
448+ arg0 = args[0].(context.Context)
449+ }
450+ var arg1 domain.Principal
451+ if args[1] != nil {
452+ arg1 = args[1].(domain.Principal)
453+ }
454+ var arg2 []byte
455+ if args[2] != nil {
456+ arg2 = args[2].([]byte)
457+ }
458+ run(
459+ arg0,
460+ arg1,
461+ arg2,
462+ )
463+ })
464+ return _c
465+}
466+
467+func (_c *WebPasskeyService_Remove_Call) Return(err error) *WebPasskeyService_Remove_Call {
468+ _c.Call.Return(err)
469+ return _c
470+}
471+
472+func (_c *WebPasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *WebPasskeyService_Remove_Call {
473+ _c.Call.Return(run)
474+ return _c
475+}
@@ -0,0 +1,475 @@
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+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewWebPasskeyService creates a new instance of WebPasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewWebPasskeyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *WebPasskeyService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &WebPasskeyService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// WebPasskeyService is an autogenerated mock type for the WebPasskeyService type
39+type WebPasskeyService struct {
40+ mock.Mock
41+}
42+
43+type WebPasskeyService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter {
48+ return &WebPasskeyService_Expecter{mock: &_m.Mock}
49+}
50+
51+// BeginLogin provides a mock function for the type WebPasskeyService
52+func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte, error) {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for BeginLogin")
57+ }
58+
59+ var r0 string
60+ var r1 []byte
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
63+ return returnFunc(ctx)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
66+ r0 = returnFunc(ctx)
67+ } else {
68+ r0 = ret.Get(0).(string)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
71+ r1 = returnFunc(ctx)
72+ } else {
73+ if ret.Get(1) != nil {
74+ r1 = ret.Get(1).([]byte)
75+ }
76+ }
77+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
78+ r2 = returnFunc(ctx)
79+ } else {
80+ r2 = ret.Error(2)
81+ }
82+ return r0, r1, r2
83+}
84+
85+// WebPasskeyService_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin'
86+type WebPasskeyService_BeginLogin_Call struct {
87+ *mock.Call
88+}
89+
90+// BeginLogin is a helper method to define mock.On call
91+// - ctx context.Context
92+func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any) *WebPasskeyService_BeginLogin_Call {
93+ return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx)}
94+}
95+
96+func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context)) *WebPasskeyService_BeginLogin_Call {
97+ _c.Call.Run(func(args mock.Arguments) {
98+ var arg0 context.Context
99+ if args[0] != nil {
100+ arg0 = args[0].(context.Context)
101+ }
102+ run(
103+ arg0,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginLogin_Call {
110+ _c.Call.Return(ceremony, options, err)
111+ return _c
112+}
113+
114+func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
118+
119+// BeginRegistration provides a mock function for the type WebPasskeyService
120+func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
121+ ret := _mock.Called(ctx, p)
122+
123+ if len(ret) == 0 {
124+ panic("no return value specified for BeginRegistration")
125+ }
126+
127+ var r0 string
128+ var r1 []byte
129+ var r2 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (string, []byte, error)); ok {
131+ return returnFunc(ctx, p)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) string); ok {
134+ r0 = returnFunc(ctx, p)
135+ } else {
136+ r0 = ret.Get(0).(string)
137+ }
138+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) []byte); ok {
139+ r1 = returnFunc(ctx, p)
140+ } else {
141+ if ret.Get(1) != nil {
142+ r1 = ret.Get(1).([]byte)
143+ }
144+ }
145+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal) error); ok {
146+ r2 = returnFunc(ctx, p)
147+ } else {
148+ r2 = ret.Error(2)
149+ }
150+ return r0, r1, r2
151+}
152+
153+// WebPasskeyService_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration'
154+type WebPasskeyService_BeginRegistration_Call struct {
155+ *mock.Call
156+}
157+
158+// BeginRegistration is a helper method to define mock.On call
159+// - ctx context.Context
160+// - p domain.Principal
161+func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any) *WebPasskeyService_BeginRegistration_Call {
162+ return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p)}
163+}
164+
165+func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_BeginRegistration_Call {
166+ _c.Call.Run(func(args mock.Arguments) {
167+ var arg0 context.Context
168+ if args[0] != nil {
169+ arg0 = args[0].(context.Context)
170+ }
171+ var arg1 domain.Principal
172+ if args[1] != nil {
173+ arg1 = args[1].(domain.Principal)
174+ }
175+ run(
176+ arg0,
177+ arg1,
178+ )
179+ })
180+ return _c
181+}
182+
183+func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginRegistration_Call {
184+ _c.Call.Return(ceremony, options, err)
185+ return _c
186+}
187+
188+func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
189+ _c.Call.Return(run)
190+ return _c
191+}
192+
193+// FinishLogin provides a mock function for the type WebPasskeyService
194+func (_mock *WebPasskeyService) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
195+ ret := _mock.Called(ctx, ceremony, response)
196+
197+ if len(ret) == 0 {
198+ panic("no return value specified for FinishLogin")
199+ }
200+
201+ var r0 string
202+ var r1 time.Duration
203+ var r2 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) (string, time.Duration, error)); ok {
205+ return returnFunc(ctx, ceremony, response)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) string); ok {
208+ r0 = returnFunc(ctx, ceremony, response)
209+ } else {
210+ r0 = ret.Get(0).(string)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte) time.Duration); ok {
213+ r1 = returnFunc(ctx, ceremony, response)
214+ } else {
215+ r1 = ret.Get(1).(time.Duration)
216+ }
217+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, []byte) error); ok {
218+ r2 = returnFunc(ctx, ceremony, response)
219+ } else {
220+ r2 = ret.Error(2)
221+ }
222+ return r0, r1, r2
223+}
224+
225+// WebPasskeyService_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin'
226+type WebPasskeyService_FinishLogin_Call struct {
227+ *mock.Call
228+}
229+
230+// FinishLogin is a helper method to define mock.On call
231+// - ctx context.Context
232+// - ceremony string
233+// - response []byte
234+func (_e *WebPasskeyService_Expecter) FinishLogin(ctx any, ceremony any, response any) *WebPasskeyService_FinishLogin_Call {
235+ return &WebPasskeyService_FinishLogin_Call{Call: _e.mock.On("FinishLogin", ctx, ceremony, response)}
236+}
237+
238+func (_c *WebPasskeyService_FinishLogin_Call) Run(run func(ctx context.Context, ceremony string, response []byte)) *WebPasskeyService_FinishLogin_Call {
239+ _c.Call.Run(func(args mock.Arguments) {
240+ var arg0 context.Context
241+ if args[0] != nil {
242+ arg0 = args[0].(context.Context)
243+ }
244+ var arg1 string
245+ if args[1] != nil {
246+ arg1 = args[1].(string)
247+ }
248+ var arg2 []byte
249+ if args[2] != nil {
250+ arg2 = args[2].([]byte)
251+ }
252+ run(
253+ arg0,
254+ arg1,
255+ arg2,
256+ )
257+ })
258+ return _c
259+}
260+
261+func (_c *WebPasskeyService_FinishLogin_Call) Return(token string, lifetime time.Duration, err error) *WebPasskeyService_FinishLogin_Call {
262+ _c.Call.Return(token, lifetime, err)
263+ return _c
264+}
265+
266+func (_c *WebPasskeyService_FinishLogin_Call) RunAndReturn(run func(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error)) *WebPasskeyService_FinishLogin_Call {
267+ _c.Call.Return(run)
268+ return _c
269+}
270+
271+// FinishRegistration provides a mock function for the type WebPasskeyService
272+func (_mock *WebPasskeyService) FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte) error {
273+ ret := _mock.Called(ctx, p, ceremony, name, response)
274+
275+ if len(ret) == 0 {
276+ panic("no return value specified for FinishRegistration")
277+ }
278+
279+ var r0 error
280+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) error); ok {
281+ r0 = returnFunc(ctx, p, ceremony, name, response)
282+ } else {
283+ r0 = ret.Error(0)
284+ }
285+ return r0
286+}
287+
288+// WebPasskeyService_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration'
289+type WebPasskeyService_FinishRegistration_Call struct {
290+ *mock.Call
291+}
292+
293+// FinishRegistration is a helper method to define mock.On call
294+// - ctx context.Context
295+// - p domain.Principal
296+// - ceremony string
297+// - name string
298+// - response []byte
299+func (_e *WebPasskeyService_Expecter) FinishRegistration(ctx any, p any, ceremony any, name any, response any) *WebPasskeyService_FinishRegistration_Call {
300+ return &WebPasskeyService_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, p, ceremony, name, response)}
301+}
302+
303+func (_c *WebPasskeyService_FinishRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte)) *WebPasskeyService_FinishRegistration_Call {
304+ _c.Call.Run(func(args mock.Arguments) {
305+ var arg0 context.Context
306+ if args[0] != nil {
307+ arg0 = args[0].(context.Context)
308+ }
309+ var arg1 domain.Principal
310+ if args[1] != nil {
311+ arg1 = args[1].(domain.Principal)
312+ }
313+ var arg2 string
314+ if args[2] != nil {
315+ arg2 = args[2].(string)
316+ }
317+ var arg3 string
318+ if args[3] != nil {
319+ arg3 = args[3].(string)
320+ }
321+ var arg4 []byte
322+ if args[4] != nil {
323+ arg4 = args[4].([]byte)
324+ }
325+ run(
326+ arg0,
327+ arg1,
328+ arg2,
329+ arg3,
330+ arg4,
331+ )
332+ })
333+ return _c
334+}
335+
336+func (_c *WebPasskeyService_FinishRegistration_Call) Return(err error) *WebPasskeyService_FinishRegistration_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *WebPasskeyService_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, ceremony string, name string, response []byte) error) *WebPasskeyService_FinishRegistration_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// List provides a mock function for the type WebPasskeyService
347+func (_mock *WebPasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
348+ ret := _mock.Called(ctx, p)
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for List")
352+ }
353+
354+ var r0 []domain.Passkey
355+ var r1 error
356+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
357+ return returnFunc(ctx, p)
358+ }
359+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
360+ r0 = returnFunc(ctx, p)
361+ } else {
362+ if ret.Get(0) != nil {
363+ r0 = ret.Get(0).([]domain.Passkey)
364+ }
365+ }
366+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
367+ r1 = returnFunc(ctx, p)
368+ } else {
369+ r1 = ret.Error(1)
370+ }
371+ return r0, r1
372+}
373+
374+// WebPasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
375+type WebPasskeyService_List_Call struct {
376+ *mock.Call
377+}
378+
379+// List is a helper method to define mock.On call
380+// - ctx context.Context
381+// - p domain.Principal
382+func (_e *WebPasskeyService_Expecter) List(ctx any, p any) *WebPasskeyService_List_Call {
383+ return &WebPasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
384+}
385+
386+func (_c *WebPasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_List_Call {
387+ _c.Call.Run(func(args mock.Arguments) {
388+ var arg0 context.Context
389+ if args[0] != nil {
390+ arg0 = args[0].(context.Context)
391+ }
392+ var arg1 domain.Principal
393+ if args[1] != nil {
394+ arg1 = args[1].(domain.Principal)
395+ }
396+ run(
397+ arg0,
398+ arg1,
399+ )
400+ })
401+ return _c
402+}
403+
404+func (_c *WebPasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *WebPasskeyService_List_Call {
405+ _c.Call.Return(passkeys, err)
406+ return _c
407+}
408+
409+func (_c *WebPasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *WebPasskeyService_List_Call {
410+ _c.Call.Return(run)
411+ return _c
412+}
413+
414+// Remove provides a mock function for the type WebPasskeyService
415+func (_mock *WebPasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
416+ ret := _mock.Called(ctx, p, id)
417+
418+ if len(ret) == 0 {
419+ panic("no return value specified for Remove")
420+ }
421+
422+ var r0 error
423+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
424+ r0 = returnFunc(ctx, p, id)
425+ } else {
426+ r0 = ret.Error(0)
427+ }
428+ return r0
429+}
430+
431+// WebPasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
432+type WebPasskeyService_Remove_Call struct {
433+ *mock.Call
434+}
435+
436+// Remove is a helper method to define mock.On call
437+// - ctx context.Context
438+// - p domain.Principal
439+// - id []byte
440+func (_e *WebPasskeyService_Expecter) Remove(ctx any, p any, id any) *WebPasskeyService_Remove_Call {
441+ return &WebPasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
442+}
443+
444+func (_c *WebPasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *WebPasskeyService_Remove_Call {
445+ _c.Call.Run(func(args mock.Arguments) {
446+ var arg0 context.Context
447+ if args[0] != nil {
448+ arg0 = args[0].(context.Context)
449+ }
450+ var arg1 domain.Principal
451+ if args[1] != nil {
452+ arg1 = args[1].(domain.Principal)
453+ }
454+ var arg2 []byte
455+ if args[2] != nil {
456+ arg2 = args[2].([]byte)
457+ }
458+ run(
459+ arg0,
460+ arg1,
461+ arg2,
462+ )
463+ })
464+ return _c
465+}
466+
467+func (_c *WebPasskeyService_Remove_Call) Return(err error) *WebPasskeyService_Remove_Call {
468+ _c.Call.Return(err)
469+ return _c
470+}
471+
472+func (_c *WebPasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *WebPasskeyService_Remove_Call {
473+ _c.Call.Return(run)
474+ return _c
475+}
M internal/ports/services.go +30 -0
@@ -117,6 +117,36 @@ type WebSessionService interface {
117117 Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)
118118 }
119119
120+// PasskeyService is the owner's passkey use cases the API serves.
121+// *usecase.Passkeys implements it. Registering a passkey and signing in with
122+// one are browser ceremonies and are not part of it.
123+type PasskeyService interface {
124+ // List returns the passkeys, oldest first.
125+ List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
126+ // Remove deletes the passkey with that credential id, or returns
127+ // domain.ErrNotFound, and signs every browser out.
128+ Remove(ctx context.Context, p domain.Principal, id []byte) error
129+}
130+
131+// WebPasskeyService is the passkey side of the use cases, as the web needs
132+// it. The composition root implements it over *usecase.Passkeys. A ceremony is
133+// the opaque identifier BeginLogin and BeginRegistration return; options is
134+// the JSON the browser passes to the WebAuthn API.
135+type WebPasskeyService interface {
136+ List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
137+ Remove(ctx context.Context, p domain.Principal, id []byte) error
138+ // BeginLogin starts a sign-in. Anonymous callers may.
139+ BeginLogin(ctx context.Context) (ceremony string, options []byte, err error)
140+ // FinishLogin checks the browser's response and returns the session token
141+ // and how long it lasts, or ErrLinkRejected for any refusal.
142+ FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
143+ // BeginRegistration starts adding a passkey. Only the owner may.
144+ BeginRegistration(ctx context.Context, p domain.Principal) (ceremony string, options []byte, err error)
145+ // FinishRegistration checks the browser's response and stores the
146+ // passkey under name (empty means the default name).
147+ FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error
148+}
149+
120150 // PushService opens a repository for a push: it is domain.ErrUnauthenticated
121151 // for an anonymous caller, domain.ErrNotFound for a repository the caller may
122152 // not write and cannot see (or that is missing and may not be created),
@@ -117,6 +117,36 @@ type WebSessionService interface {
117 Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)117 Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)
118 }118 }
119 119
120+// PasskeyService is the owner's passkey use cases the API serves.
121+// *usecase.Passkeys implements it. Registering a passkey and signing in with
122+// one are browser ceremonies and are not part of it.
123+type PasskeyService interface {
124+ // List returns the passkeys, oldest first.
125+ List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
126+ // Remove deletes the passkey with that credential id, or returns
127+ // domain.ErrNotFound, and signs every browser out.
128+ Remove(ctx context.Context, p domain.Principal, id []byte) error
129+}
130+
131+// WebPasskeyService is the passkey side of the use cases, as the web needs
132+// it. The composition root implements it over *usecase.Passkeys. A ceremony is
133+// the opaque identifier BeginLogin and BeginRegistration return; options is
134+// the JSON the browser passes to the WebAuthn API.
135+type WebPasskeyService interface {
136+ List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
137+ Remove(ctx context.Context, p domain.Principal, id []byte) error
138+ // BeginLogin starts a sign-in. Anonymous callers may.
139+ BeginLogin(ctx context.Context) (ceremony string, options []byte, err error)
140+ // FinishLogin checks the browser's response and returns the session token
141+ // and how long it lasts, or ErrLinkRejected for any refusal.
142+ FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
143+ // BeginRegistration starts adding a passkey. Only the owner may.
144+ BeginRegistration(ctx context.Context, p domain.Principal) (ceremony string, options []byte, err error)
145+ // FinishRegistration checks the browser's response and stores the
146+ // passkey under name (empty means the default name).
147+ FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error
148+}
149+
120 // PushService opens a repository for a push: it is domain.ErrUnauthenticated150 // PushService opens a repository for a push: it is domain.ErrUnauthenticated
121 // for an anonymous caller, domain.ErrNotFound for a repository the caller may151 // for an anonymous caller, domain.ErrNotFound for a repository the caller may
122 // not write and cannot see (or that is missing and may not be created),152 // not write and cannot see (or that is missing and may not be created),
A internal/usecase/passkeys.go +315 -0
@@ -0,0 +1,315 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "errors"
12+ "fmt"
13+ "sync"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Owner passkeys (ADR 0026). A ceremony is a registration or a login that the
23+// browser carries out between two requests; its data lives in memory only.
24+const (
25+ // PasskeyCeremonyTTL is how long a ceremony may take.
26+ PasskeyCeremonyTTL = 2 * time.Minute
27+ // MaxPendingPasskeyRegistrations bounds the registrations that wait to be
28+ // finished. Only the owner starts one.
29+ MaxPendingPasskeyRegistrations = 16
30+ // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
31+ // Anyone may start one, so on overflow the oldest is dropped instead of
32+ // refusing: anonymous callers cannot lock the owner out.
33+ MaxPendingPasskeyLogins = 256
34+
35+ // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
36+ ceremonyIDBytes = 32
37+)
38+
39+type ceremonyKind uint8
40+
41+const (
42+ ceremonyRegister ceremonyKind = iota + 1
43+ ceremonyLogin
44+)
45+
46+// ceremony is a begun registration or login.
47+type ceremony struct {
48+ kind ceremonyKind
49+ data []byte // opaque, from the verifier
50+ expires time.Time
51+ seq uint64 // creation order
52+}
53+
54+// Passkeys are the use cases of the owner's passkeys: registering, signing in,
55+// listing and removing them.
56+type Passkeys struct {
57+ store ports.PasskeyStore
58+ verifier ports.PasskeyVerifier
59+ sessions *Sessions
60+ clock ports.Clock
61+
62+ // writeMu serializes the writes to the store (Create, Update, Delete) and
63+ // the session they make or void, so that a removed passkey cannot sign in.
64+ writeMu sync.Mutex
65+
66+ mu sync.Mutex
67+ ceremonies map[[sha256.Size]byte]ceremony // by hash of the identifier
68+ seq uint64
69+}
70+
71+var _ ports.PasskeyService = (*Passkeys)(nil)
72+
73+// NewPasskeys builds the passkey use cases. Sessions are issued and voided
74+// through sessions.
75+func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessions *Sessions, clock ports.Clock) *Passkeys {
76+ return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
77+}
78+
79+// begin keeps a ceremony and returns its identifier. It sweeps the expired
80+// ones first. A registration over the limit gives domain.ErrResourceExhausted;
81+// a login over it drops the oldest login.
82+func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {
83+ var raw [ceremonyIDBytes]byte
84+ if _, err := rand.Read(raw[:]); err != nil {
85+ return "", fmt.Errorf("generate passkey ceremony: %w", err)
86+ }
87+ id := base64.RawURLEncoding.EncodeToString(raw[:])
88+ now := s.clock.Now()
89+
90+ s.mu.Lock()
91+ defer s.mu.Unlock()
92+ pending := 0
93+ var oldest [sha256.Size]byte
94+ var oldestSeq uint64
95+ for h, c := range s.ceremonies {
96+ if !now.Before(c.expires) {
97+ delete(s.ceremonies, h)
98+ continue
99+ }
100+ if c.kind != kind {
101+ continue
102+ }
103+ if pending == 0 || c.seq < oldestSeq {
104+ oldest, oldestSeq = h, c.seq
105+ }
106+ pending++
107+ }
108+ switch kind {
109+ case ceremonyRegister:
110+ if pending >= MaxPendingPasskeyRegistrations {
111+ return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
112+ }
113+ case ceremonyLogin:
114+ if pending >= MaxPendingPasskeyLogins {
115+ delete(s.ceremonies, oldest)
116+ }
117+ }
118+ s.seq++
119+ s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq}
120+ return id, nil
121+}
122+
123+// take removes the ceremony and returns its data. It is single use, also when
124+// the ceremony expired or is of another kind. ok is false for an unknown,
125+// expired or mismatched ceremony.
126+func (s *Passkeys) take(id string, kind ceremonyKind) (data []byte, ok bool) {
127+ h := sha256.Sum256([]byte(id))
128+ s.mu.Lock()
129+ c, found := s.ceremonies[h]
130+ delete(s.ceremonies, h)
131+ s.mu.Unlock()
132+ if !found || c.kind != kind || !s.clock.Now().Before(c.expires) {
133+ return nil, false
134+ }
135+ return c.data, true
136+}
137+
138+// BeginRegistration starts adding a passkey. Only the owner may. It gives
139+// domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too many
140+// registrations wait. It returns the ceremony identifier and the options for
141+// the browser.
142+func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
143+ if err := requireOwner(p); err != nil {
144+ return "", nil, err
145+ }
146+ existing, err := s.store.List(ctx)
147+ if err != nil {
148+ return "", nil, fmt.Errorf("list passkeys: %w", err)
149+ }
150+ if len(existing) >= domain.MaxPasskeys {
151+ return "", nil, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
152+ }
153+ handle, err := s.store.UserHandle(ctx)
154+ if err != nil {
155+ return "", nil, fmt.Errorf("load passkey user handle: %w", err)
156+ }
157+ options, data, err := s.verifier.BeginRegistration(handle, existing)
158+ if err != nil {
159+ return "", nil, err
160+ }
161+ id, err := s.begin(ceremonyRegister, data)
162+ if err != nil {
163+ return "", nil, err
164+ }
165+ return id, options, nil
166+}
167+
168+// FinishRegistration checks the browser's response and stores the passkey
169+// under name; an empty name means domain.DefaultPasskeyName. A ceremony that is
170+// unknown, expired, used or of the wrong kind, and a response the verifier
171+// refuses, give domain.ErrUnauthenticated; a bad name matches
172+// domain.ErrInvalidText; a credential already registered gives
173+// domain.ErrAlreadyExists; too many passkeys, domain.ErrResourceExhausted.
174+func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, ceremonyID, name string, response []byte) (domain.Passkey, error) {
175+ if err := requireOwner(p); err != nil {
176+ return domain.Passkey{}, err
177+ }
178+ if name == "" {
179+ name = domain.DefaultPasskeyName
180+ }
181+ parsed, err := domain.ParsePasskeyName(name) // before take: a bad name does not burn the ceremony
182+ if err != nil {
183+ return domain.Passkey{}, fmt.Errorf("passkey name: %w", err)
184+ }
185+ data, ok := s.take(ceremonyID, ceremonyRegister)
186+ if !ok {
187+ return domain.Passkey{}, fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
188+ }
189+ handle, err := s.store.UserHandle(ctx)
190+ if err != nil {
191+ return domain.Passkey{}, fmt.Errorf("load passkey user handle: %w", err)
192+ }
193+ pk, err := s.verifier.FinishRegistration(handle, data, response)
194+ if err != nil {
195+ return domain.Passkey{}, err
196+ }
197+ if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
198+ return domain.Passkey{}, fmt.Errorf("%w: credential id has %d bytes", domain.ErrUnauthenticated, n)
199+ }
200+ pk.Name = parsed.String()
201+ pk.CreatedAt = s.clock.Now().UTC()
202+ pk.LastUsedAt = time.Time{}
203+
204+ s.writeMu.Lock()
205+ defer s.writeMu.Unlock()
206+ existing, err := s.store.List(ctx)
207+ if err != nil {
208+ return domain.Passkey{}, fmt.Errorf("list passkeys: %w", err)
209+ }
210+ if len(existing) >= domain.MaxPasskeys {
211+ return domain.Passkey{}, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
212+ }
213+ if err := s.store.Create(ctx, pk); err != nil {
214+ return domain.Passkey{}, err
215+ }
216+ log := zerowrap.FromCtx(ctx)
217+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
218+ return pk, nil
219+}
220+
221+// BeginLogin starts a passkey sign-in. Anonymous callers may. It returns the
222+// ceremony identifier and the options for the browser.
223+func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {
224+ options, data, err := s.verifier.BeginLogin()
225+ if err != nil {
226+ return "", nil, err
227+ }
228+ id, err := s.begin(ceremonyLogin, data)
229+ if err != nil {
230+ return "", nil, err
231+ }
232+ return id, options, nil
233+}
234+
235+// FinishLogin checks the browser's response and opens the owner's session,
236+// like a login link does. Whatever the reason of a refusal (unknown or
237+// expired ceremony, unknown passkey, bad signature, missing user verification,
238+// cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
239+// store is returned as it is, so that the caller fails closed.
240+func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
241+ data, ok := s.take(ceremonyID, ceremonyLogin)
242+ if !ok {
243+ return "", time.Time{}, domain.ErrUnauthenticated
244+ }
245+ handle, err := s.store.UserHandle(ctx)
246+ if err != nil {
247+ return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
248+ }
249+ log := zerowrap.FromCtx(ctx)
250+ pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })
251+ if err != nil {
252+ if errors.Is(err, domain.ErrPasskeyCloned) {
253+ log.Warn().Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
254+ }
255+ if errors.Is(err, domain.ErrUnauthenticated) {
256+ return "", time.Time{}, domain.ErrUnauthenticated
257+ }
258+ return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
259+ }
260+
261+ // Recording the use and opening the session are one step with respect to
262+ // Remove, so that a passkey removed meanwhile cannot sign in.
263+ s.writeMu.Lock()
264+ defer s.writeMu.Unlock()
265+ pk.LastUsedAt = s.clock.Now().UTC()
266+ if err := s.store.Update(ctx, pk); err != nil {
267+ if errors.Is(err, domain.ErrNotFound) {
268+ return "", time.Time{}, domain.ErrUnauthenticated
269+ }
270+ return "", time.Time{}, fmt.Errorf("record passkey use: %w", err)
271+ }
272+ token, expiresAt, err = s.sessions.issueOwner(ctx)
273+ if err != nil {
274+ return "", time.Time{}, err
275+ }
276+ log.Info().Str("passkey", passkeyRef(pk.ID)).Time("expires_at", expiresAt).Msg("owner signed in with a passkey")
277+ return token, expiresAt, nil
278+}
279+
280+// List returns the passkeys, oldest first. Only the owner may.
281+func (s *Passkeys) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
282+ if err := requireOwner(p); err != nil {
283+ return nil, err
284+ }
285+ return s.store.List(ctx)
286+}
287+
288+// Remove deletes a passkey and signs every browser out, like revoking a token
289+// with owner rights does. Only the owner may. An unknown id gives
290+// domain.ErrNotFound.
291+func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) error {
292+ if err := requireOwner(p); err != nil {
293+ return err
294+ }
295+ s.writeMu.Lock()
296+ defer s.writeMu.Unlock()
297+ if err := s.store.Delete(ctx, id); err != nil {
298+ return err
299+ }
300+ log := zerowrap.FromCtx(ctx)
301+ log.Info().Str("passkey", passkeyRef(id)).Str("removed_by", p.Label()).Str("removed_by_token", p.TokenID()).Msg("passkey removed")
302+ epoch, err := s.sessions.bumpEpoch(ctx)
303+ if err != nil {
304+ return fmt.Errorf("passkey %s removed, but signing out the web sessions failed: %w", passkeyRef(id), err)
305+ }
306+ log.Info().Str("passkey", passkeyRef(id)).Uint64("epoch", epoch).Msg("every web session signed out: a passkey was removed")
307+ return nil
308+}
309+
310+// passkeyRef is how logs name a passkey: the first 8 hex characters of the
311+// SHA-256 of its credential id.
312+func passkeyRef(id []byte) string {
313+ sum := sha256.Sum256(id)
314+ return hex.EncodeToString(sum[:])[:8]
315+}
@@ -0,0 +1,315 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "errors"
12+ "fmt"
13+ "sync"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Owner passkeys (ADR 0026). A ceremony is a registration or a login that the
23+// browser carries out between two requests; its data lives in memory only.
24+const (
25+ // PasskeyCeremonyTTL is how long a ceremony may take.
26+ PasskeyCeremonyTTL = 2 * time.Minute
27+ // MaxPendingPasskeyRegistrations bounds the registrations that wait to be
28+ // finished. Only the owner starts one.
29+ MaxPendingPasskeyRegistrations = 16
30+ // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
31+ // Anyone may start one, so on overflow the oldest is dropped instead of
32+ // refusing: anonymous callers cannot lock the owner out.
33+ MaxPendingPasskeyLogins = 256
34+
35+ // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
36+ ceremonyIDBytes = 32
37+)
38+
39+type ceremonyKind uint8
40+
41+const (
42+ ceremonyRegister ceremonyKind = iota + 1
43+ ceremonyLogin
44+)
45+
46+// ceremony is a begun registration or login.
47+type ceremony struct {
48+ kind ceremonyKind
49+ data []byte // opaque, from the verifier
50+ expires time.Time
51+ seq uint64 // creation order
52+}
53+
54+// Passkeys are the use cases of the owner's passkeys: registering, signing in,
55+// listing and removing them.
56+type Passkeys struct {
57+ store ports.PasskeyStore
58+ verifier ports.PasskeyVerifier
59+ sessions *Sessions
60+ clock ports.Clock
61+
62+ // writeMu serializes the writes to the store (Create, Update, Delete) and
63+ // the session they make or void, so that a removed passkey cannot sign in.
64+ writeMu sync.Mutex
65+
66+ mu sync.Mutex
67+ ceremonies map[[sha256.Size]byte]ceremony // by hash of the identifier
68+ seq uint64
69+}
70+
71+var _ ports.PasskeyService = (*Passkeys)(nil)
72+
73+// NewPasskeys builds the passkey use cases. Sessions are issued and voided
74+// through sessions.
75+func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessions *Sessions, clock ports.Clock) *Passkeys {
76+ return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
77+}
78+
79+// begin keeps a ceremony and returns its identifier. It sweeps the expired
80+// ones first. A registration over the limit gives domain.ErrResourceExhausted;
81+// a login over it drops the oldest login.
82+func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {
83+ var raw [ceremonyIDBytes]byte
84+ if _, err := rand.Read(raw[:]); err != nil {
85+ return "", fmt.Errorf("generate passkey ceremony: %w", err)
86+ }
87+ id := base64.RawURLEncoding.EncodeToString(raw[:])
88+ now := s.clock.Now()
89+
90+ s.mu.Lock()
91+ defer s.mu.Unlock()
92+ pending := 0
93+ var oldest [sha256.Size]byte
94+ var oldestSeq uint64
95+ for h, c := range s.ceremonies {
96+ if !now.Before(c.expires) {
97+ delete(s.ceremonies, h)
98+ continue
99+ }
100+ if c.kind != kind {
101+ continue
102+ }
103+ if pending == 0 || c.seq < oldestSeq {
104+ oldest, oldestSeq = h, c.seq
105+ }
106+ pending++
107+ }
108+ switch kind {
109+ case ceremonyRegister:
110+ if pending >= MaxPendingPasskeyRegistrations {
111+ return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
112+ }
113+ case ceremonyLogin:
114+ if pending >= MaxPendingPasskeyLogins {
115+ delete(s.ceremonies, oldest)
116+ }
117+ }
118+ s.seq++
119+ s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq}
120+ return id, nil
121+}
122+
123+// take removes the ceremony and returns its data. It is single use, also when
124+// the ceremony expired or is of another kind. ok is false for an unknown,
125+// expired or mismatched ceremony.
126+func (s *Passkeys) take(id string, kind ceremonyKind) (data []byte, ok bool) {
127+ h := sha256.Sum256([]byte(id))
128+ s.mu.Lock()
129+ c, found := s.ceremonies[h]
130+ delete(s.ceremonies, h)
131+ s.mu.Unlock()
132+ if !found || c.kind != kind || !s.clock.Now().Before(c.expires) {
133+ return nil, false
134+ }
135+ return c.data, true
136+}
137+
138+// BeginRegistration starts adding a passkey. Only the owner may. It gives
139+// domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too many
140+// registrations wait. It returns the ceremony identifier and the options for
141+// the browser.
142+func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
143+ if err := requireOwner(p); err != nil {
144+ return "", nil, err
145+ }
146+ existing, err := s.store.List(ctx)
147+ if err != nil {
148+ return "", nil, fmt.Errorf("list passkeys: %w", err)
149+ }
150+ if len(existing) >= domain.MaxPasskeys {
151+ return "", nil, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
152+ }
153+ handle, err := s.store.UserHandle(ctx)
154+ if err != nil {
155+ return "", nil, fmt.Errorf("load passkey user handle: %w", err)
156+ }
157+ options, data, err := s.verifier.BeginRegistration(handle, existing)
158+ if err != nil {
159+ return "", nil, err
160+ }
161+ id, err := s.begin(ceremonyRegister, data)
162+ if err != nil {
163+ return "", nil, err
164+ }
165+ return id, options, nil
166+}
167+
168+// FinishRegistration checks the browser's response and stores the passkey
169+// under name; an empty name means domain.DefaultPasskeyName. A ceremony that is
170+// unknown, expired, used or of the wrong kind, and a response the verifier
171+// refuses, give domain.ErrUnauthenticated; a bad name matches
172+// domain.ErrInvalidText; a credential already registered gives
173+// domain.ErrAlreadyExists; too many passkeys, domain.ErrResourceExhausted.
174+func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, ceremonyID, name string, response []byte) (domain.Passkey, error) {
175+ if err := requireOwner(p); err != nil {
176+ return domain.Passkey{}, err
177+ }
178+ if name == "" {
179+ name = domain.DefaultPasskeyName
180+ }
181+ parsed, err := domain.ParsePasskeyName(name) // before take: a bad name does not burn the ceremony
182+ if err != nil {
183+ return domain.Passkey{}, fmt.Errorf("passkey name: %w", err)
184+ }
185+ data, ok := s.take(ceremonyID, ceremonyRegister)
186+ if !ok {
187+ return domain.Passkey{}, fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
188+ }
189+ handle, err := s.store.UserHandle(ctx)
190+ if err != nil {
191+ return domain.Passkey{}, fmt.Errorf("load passkey user handle: %w", err)
192+ }
193+ pk, err := s.verifier.FinishRegistration(handle, data, response)
194+ if err != nil {
195+ return domain.Passkey{}, err
196+ }
197+ if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
198+ return domain.Passkey{}, fmt.Errorf("%w: credential id has %d bytes", domain.ErrUnauthenticated, n)
199+ }
200+ pk.Name = parsed.String()
201+ pk.CreatedAt = s.clock.Now().UTC()
202+ pk.LastUsedAt = time.Time{}
203+
204+ s.writeMu.Lock()
205+ defer s.writeMu.Unlock()
206+ existing, err := s.store.List(ctx)
207+ if err != nil {
208+ return domain.Passkey{}, fmt.Errorf("list passkeys: %w", err)
209+ }
210+ if len(existing) >= domain.MaxPasskeys {
211+ return domain.Passkey{}, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
212+ }
213+ if err := s.store.Create(ctx, pk); err != nil {
214+ return domain.Passkey{}, err
215+ }
216+ log := zerowrap.FromCtx(ctx)
217+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
218+ return pk, nil
219+}
220+
221+// BeginLogin starts a passkey sign-in. Anonymous callers may. It returns the
222+// ceremony identifier and the options for the browser.
223+func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {
224+ options, data, err := s.verifier.BeginLogin()
225+ if err != nil {
226+ return "", nil, err
227+ }
228+ id, err := s.begin(ceremonyLogin, data)
229+ if err != nil {
230+ return "", nil, err
231+ }
232+ return id, options, nil
233+}
234+
235+// FinishLogin checks the browser's response and opens the owner's session,
236+// like a login link does. Whatever the reason of a refusal (unknown or
237+// expired ceremony, unknown passkey, bad signature, missing user verification,
238+// cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
239+// store is returned as it is, so that the caller fails closed.
240+func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
241+ data, ok := s.take(ceremonyID, ceremonyLogin)
242+ if !ok {
243+ return "", time.Time{}, domain.ErrUnauthenticated
244+ }
245+ handle, err := s.store.UserHandle(ctx)
246+ if err != nil {
247+ return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
248+ }
249+ log := zerowrap.FromCtx(ctx)
250+ pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })
251+ if err != nil {
252+ if errors.Is(err, domain.ErrPasskeyCloned) {
253+ log.Warn().Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
254+ }
255+ if errors.Is(err, domain.ErrUnauthenticated) {
256+ return "", time.Time{}, domain.ErrUnauthenticated
257+ }
258+ return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
259+ }
260+
261+ // Recording the use and opening the session are one step with respect to
262+ // Remove, so that a passkey removed meanwhile cannot sign in.
263+ s.writeMu.Lock()
264+ defer s.writeMu.Unlock()
265+ pk.LastUsedAt = s.clock.Now().UTC()
266+ if err := s.store.Update(ctx, pk); err != nil {
267+ if errors.Is(err, domain.ErrNotFound) {
268+ return "", time.Time{}, domain.ErrUnauthenticated
269+ }
270+ return "", time.Time{}, fmt.Errorf("record passkey use: %w", err)
271+ }
272+ token, expiresAt, err = s.sessions.issueOwner(ctx)
273+ if err != nil {
274+ return "", time.Time{}, err
275+ }
276+ log.Info().Str("passkey", passkeyRef(pk.ID)).Time("expires_at", expiresAt).Msg("owner signed in with a passkey")
277+ return token, expiresAt, nil
278+}
279+
280+// List returns the passkeys, oldest first. Only the owner may.
281+func (s *Passkeys) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
282+ if err := requireOwner(p); err != nil {
283+ return nil, err
284+ }
285+ return s.store.List(ctx)
286+}
287+
288+// Remove deletes a passkey and signs every browser out, like revoking a token
289+// with owner rights does. Only the owner may. An unknown id gives
290+// domain.ErrNotFound.
291+func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) error {
292+ if err := requireOwner(p); err != nil {
293+ return err
294+ }
295+ s.writeMu.Lock()
296+ defer s.writeMu.Unlock()
297+ if err := s.store.Delete(ctx, id); err != nil {
298+ return err
299+ }
300+ log := zerowrap.FromCtx(ctx)
301+ log.Info().Str("passkey", passkeyRef(id)).Str("removed_by", p.Label()).Str("removed_by_token", p.TokenID()).Msg("passkey removed")
302+ epoch, err := s.sessions.bumpEpoch(ctx)
303+ if err != nil {
304+ return fmt.Errorf("passkey %s removed, but signing out the web sessions failed: %w", passkeyRef(id), err)
305+ }
306+ log.Info().Str("passkey", passkeyRef(id)).Uint64("epoch", epoch).Msg("every web session signed out: a passkey was removed")
307+ return nil
308+}
309+
310+// passkeyRef is how logs name a passkey: the first 8 hex characters of the
311+// SHA-256 of its credential id.
312+func passkeyRef(id []byte) string {
313+ sum := sha256.Sum256(id)
314+ return hex.EncodeToString(sum[:])[:8]
315+}
A internal/usecase/passkeys_test.go +698 -0
@@ -0,0 +1,698 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "slices"
11+ "sync"
12+ "sync/atomic"
13+ "testing"
14+ "time"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+var testHandle = []byte("0123456789abcdef0123456789abcdef")
26+
27+type passkeyFixture struct {
28+ *sessionFixture
29+ store *mocks.PasskeyStore
30+ verifier *mocks.PasskeyVerifier
31+ passkeys *usecase.Passkeys
32+
33+ mu sync.Mutex
34+ stored map[string]domain.Passkey // by credential id
35+ ceremon atomic.Int64 // makes the ceremony data of each begin unique
36+}
37+
38+func newPasskeyFixture(t *testing.T) *passkeyFixture {
39+ t.Helper()
40+ f := &passkeyFixture{
41+ sessionFixture: newSessionFixture(t),
42+ store: mocks.NewPasskeyStore(t),
43+ verifier: mocks.NewPasskeyVerifier(t),
44+ stored: map[string]domain.Passkey{},
45+ }
46+ f.store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil).Maybe()
47+ f.store.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Passkey, error) {
48+ f.mu.Lock()
49+ defer f.mu.Unlock()
50+ var out []domain.Passkey
51+ for _, p := range f.stored {
52+ out = append(out, p)
53+ }
54+ slices.SortFunc(out, func(a, b domain.Passkey) int { return bytes.Compare(a.ID, b.ID) })
55+ return out, nil
56+ }).Maybe()
57+ f.store.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) (domain.Passkey, error) {
58+ f.mu.Lock()
59+ defer f.mu.Unlock()
60+ if p, ok := f.stored[string(id)]; ok {
61+ return p, nil
62+ }
63+ return domain.Passkey{}, domain.ErrNotFound
64+ }).Maybe()
65+ f.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
66+ f.mu.Lock()
67+ defer f.mu.Unlock()
68+ if _, ok := f.stored[string(p.ID)]; ok {
69+ return domain.ErrAlreadyExists
70+ }
71+ f.stored[string(p.ID)] = p
72+ return nil
73+ }).Maybe()
74+ f.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
75+ f.mu.Lock()
76+ defer f.mu.Unlock()
77+ if _, ok := f.stored[string(p.ID)]; !ok {
78+ return domain.ErrNotFound
79+ }
80+ f.stored[string(p.ID)] = p
81+ return nil
82+ }).Maybe()
83+ f.store.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) error {
84+ f.mu.Lock()
85+ defer f.mu.Unlock()
86+ if _, ok := f.stored[string(id)]; !ok {
87+ return domain.ErrNotFound
88+ }
89+ delete(f.stored, string(id))
90+ return nil
91+ }).Maybe()
92+
93+ f.verifier.EXPECT().BeginRegistration(mock.Anything, mock.Anything).RunAndReturn(func([]byte, []domain.Passkey) ([]byte, []byte, error) {
94+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("register-%d", f.ceremon.Add(1))), nil
95+ }).Maybe()
96+ f.verifier.EXPECT().BeginLogin().RunAndReturn(func() ([]byte, []byte, error) {
97+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("login-%d", f.ceremon.Add(1))), nil
98+ }).Maybe()
99+
100+ f.passkeys = usecase.NewPasskeys(f.store, f.verifier, f.sessions, f.clock)
101+ return f
102+}
103+
104+// add puts a passkey in the store directly.
105+func (f *passkeyFixture) add(id string) domain.Passkey {
106+ p := domain.Passkey{ID: []byte(id), Name: id, PublicKey: []byte("k"), CreatedAt: *f.at.Load()}
107+ f.mu.Lock()
108+ f.stored[id] = p
109+ f.mu.Unlock()
110+ return p
111+}
112+
113+func (f *passkeyFixture) count() int {
114+ f.mu.Lock()
115+ defer f.mu.Unlock()
116+ return len(f.stored)
117+}
118+
119+// acceptRegistration makes the verifier accept any registration as the passkey.
120+func (f *passkeyFixture) acceptRegistration(id string) {
121+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(func(_, ceremony, _ []byte) (domain.Passkey, error) {
122+ if !bytes.HasPrefix(ceremony, []byte("register-")) {
123+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
124+ }
125+ return domain.Passkey{ID: []byte(id), PublicKey: []byte("pub"), AttestationType: "none", Transports: []string{"internal"}, BackupEligible: true}, nil
126+ }).Maybe()
127+}
128+
129+// acceptLogin makes the verifier accept a login with the stored passkey `id`,
130+// reporting the sign count.
131+func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {
132+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133+ func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
134+ if !bytes.HasPrefix(ceremony, []byte("login-")) {
135+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
136+ }
137+ p, err := lookup([]byte(id))
138+ if err != nil {
139+ return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
140+ }
141+ p.SignCount = signCount
142+ p.BackupState = true
143+ return p, nil
144+ }).Maybe()
145+}
146+
147+func ownerAndOthers() map[string]struct {
148+ p domain.Principal
149+ err error
150+} {
151+ partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
152+ return map[string]struct {
153+ p domain.Principal
154+ err error
155+ }{
156+ "root": {domain.Root(), nil},
157+ "owner session": {domain.Owner(), nil},
158+ "every scope": {allScopeToken(), nil},
159+ "scoped token": {partial, domain.ErrForbidden},
160+ "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
161+ }
162+}
163+
164+func TestPasskeysAreOwnerOnly(t *testing.T) {
165+ t.Parallel()
166+ ctx := context.Background()
167+ for name, tc := range ownerAndOthers() {
168+ t.Run(name, func(t *testing.T) {
169+ t.Parallel()
170+ f := newPasskeyFixture(t)
171+ f.add("a")
172+ f.acceptRegistration("new")
173+ check := func(what string, err error) {
174+ t.Helper()
175+ if tc.err == nil {
176+ assert.NoError(t, err, what)
177+ } else {
178+ assert.ErrorIs(t, err, tc.err, what)
179+ }
180+ }
181+ _, _, err := f.passkeys.BeginRegistration(ctx, tc.p)
182+ check("begin registration", err)
183+ _, err = f.passkeys.List(ctx, tc.p)
184+ check("list", err)
185+
186+ // A ceremony begun by the owner cannot be finished by anyone else.
187+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
188+ require.NoError(t, err)
189+ _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}"))
190+ check("finish registration", err)
191+ if tc.err != nil {
192+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "n", []byte("{}"))
193+ assert.NoError(t, err, "a refused caller does not burn the ceremony")
194+ }
195+
196+ err = f.passkeys.Remove(ctx, tc.p, []byte("a"))
197+ check("remove", err)
198+ if tc.err != nil {
199+ assert.Equal(t, 2, f.count(), "nothing removed")
200+ }
201+ })
202+ }
203+}
204+
205+func TestBeginLoginIsAnonymous(t *testing.T) {
206+ t.Parallel()
207+ f := newPasskeyFixture(t)
208+ id, options, err := f.passkeys.BeginLogin(context.Background())
209+ require.NoError(t, err)
210+ assert.Len(t, id, 43, "256 bits")
211+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
212+}
213+
214+func TestRegistration(t *testing.T) {
215+ t.Parallel()
216+ ctx := context.Background()
217+ f := newPasskeyFixture(t)
218+ f.acceptRegistration("cred-1")
219+
220+ id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
221+ require.NoError(t, err)
222+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
223+
224+ got, err := f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "Pixel 9", []byte(`{}`))
225+ require.NoError(t, err)
226+ assert.Equal(t, "Pixel 9", got.Name)
227+ assert.Equal(t, *f.at.Load(), got.CreatedAt)
228+ assert.True(t, got.LastUsedAt.IsZero())
229+ assert.Equal(t, []byte("cred-1"), got.ID)
230+ list, err := f.passkeys.List(ctx, domain.Owner())
231+ require.NoError(t, err)
232+ require.Len(t, list, 1)
233+ assert.Equal(t, got, list[0])
234+
235+ // The ceremony is single use.
236+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "again", []byte(`{}`))
237+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
238+}
239+
240+func TestRegistrationDefaultNameAndBadName(t *testing.T) {
241+ t.Parallel()
242+ ctx := context.Background()
243+ f := newPasskeyFixture(t)
244+ f.acceptRegistration("cred-1")
245+
246+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
247+ require.NoError(t, err)
248+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`))
249+ require.ErrorIs(t, err, domain.ErrInvalidText)
250+ assert.Zero(t, f.count())
251+
252+ // The verifier was not asked, and the ceremony is still there for a retry.
253+ got, err := f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "", []byte(`{}`))
254+ require.NoError(t, err)
255+ assert.Equal(t, domain.DefaultPasskeyName, got.Name)
256+}
257+
258+func TestRegistrationDuplicate(t *testing.T) {
259+ t.Parallel()
260+ ctx := context.Background()
261+ f := newPasskeyFixture(t)
262+ f.add("cred-1")
263+ f.acceptRegistration("cred-1")
264+
265+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
266+ require.NoError(t, err)
267+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`))
268+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
269+ assert.Equal(t, 1, f.count())
270+}
271+
272+func TestRegistrationRefusedByVerifier(t *testing.T) {
273+ t.Parallel()
274+ ctx := context.Background()
275+ f := newPasskeyFixture(t)
276+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
277+ Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
278+
279+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
280+ require.NoError(t, err)
281+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
282+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
283+ assert.Zero(t, f.count())
284+}
285+
286+func TestRegistrationRefusesAnIdTooLong(t *testing.T) {
287+ t.Parallel()
288+ ctx := context.Background()
289+ f := newPasskeyFixture(t)
290+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
291+ Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
292+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
293+ require.NoError(t, err)
294+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
295+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
296+ assert.Zero(t, f.count())
297+}
298+
299+// The verifier is told about the passkeys to exclude.
300+func TestBeginRegistrationExcludesExisting(t *testing.T) {
301+ t.Parallel()
302+ f := newPasskeyFixture(t)
303+ f.add("a")
304+ f.add("b")
305+ f.verifier.ExpectedCalls = nil // replace the permissive default
306+ f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
307+ return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
308+ })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
309+ _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner())
310+ require.NoError(t, err)
311+}
312+
313+func TestMaxPasskeys(t *testing.T) {
314+ t.Parallel()
315+ ctx := context.Background()
316+ f := newPasskeyFixture(t)
317+ f.acceptRegistration("one-more")
318+ for i := range domain.MaxPasskeys - 1 {
319+ f.add(fmt.Sprintf("p%02d", i))
320+ }
321+ // A ceremony begun with room for one more.
322+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
323+ require.NoError(t, err)
324+ f.add("last")
325+ assert.Equal(t, domain.MaxPasskeys, f.count())
326+
327+ // Full: no new ceremony, and the one in flight is refused at the end.
328+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
329+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
330+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
331+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
332+ assert.Equal(t, domain.MaxPasskeys, f.count())
333+
334+ // Removing one makes room again.
335+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
336+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
337+ assert.NoError(t, err)
338+}
339+
340+func TestCeremonyExpiry(t *testing.T) {
341+ t.Parallel()
342+ ctx := context.Background()
343+ f := newPasskeyFixture(t)
344+ f.acceptRegistration("cred-1")
345+ f.add("login-cred")
346+ f.acceptLogin("login-cred", 1)
347+
348+ // One second short of the TTL still works.
349+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
350+ require.NoError(t, err)
351+ login, _, err := f.passkeys.BeginLogin(ctx)
352+ require.NoError(t, err)
353+ f.advance(usecase.PasskeyCeremonyTTL - time.Second)
354+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
355+ assert.NoError(t, err)
356+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
357+ assert.NoError(t, err)
358+
359+ // At the TTL it is gone, and it stays gone.
360+ reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
361+ require.NoError(t, err)
362+ login, _, err = f.passkeys.BeginLogin(ctx)
363+ require.NoError(t, err)
364+ assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL)
365+ f.advance(usecase.PasskeyCeremonyTTL)
366+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
367+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
368+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
369+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
370+}
371+
372+// Expired ceremonies are swept on begin, so they stop counting.
373+func TestExpiredRegistrationsDoNotCount(t *testing.T) {
374+ t.Parallel()
375+ ctx := context.Background()
376+ f := newPasskeyFixture(t)
377+ for range usecase.MaxPendingPasskeyRegistrations {
378+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
379+ require.NoError(t, err)
380+ }
381+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
382+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
383+ f.advance(usecase.PasskeyCeremonyTTL)
384+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
385+ assert.NoError(t, err)
386+}
387+
388+func TestPendingRegistrationsAreBounded(t *testing.T) {
389+ t.Parallel()
390+ ctx := context.Background()
391+ f := newPasskeyFixture(t)
392+ f.acceptRegistration("cred-1")
393+ var ids []string
394+ for range usecase.MaxPendingPasskeyRegistrations {
395+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
396+ require.NoError(t, err)
397+ ids = append(ids, id)
398+ }
399+ assert.Len(t, ids, 16)
400+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
401+ require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
402+
403+ // Pending logins do not use the registrations' room, and a finished
404+ // registration frees one.
405+ _, _, err = f.passkeys.BeginLogin(ctx)
406+ require.NoError(t, err)
407+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`))
408+ require.NoError(t, err)
409+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
410+ assert.NoError(t, err)
411+}
412+
413+// Anonymous callers cannot fill the room: the 257th pending login evicts the
414+// oldest, which then fails; the newest works.
415+func TestPendingLoginsEvictTheOldest(t *testing.T) {
416+ t.Parallel()
417+ ctx := context.Background()
418+ f := newPasskeyFixture(t)
419+ f.add("cred-1")
420+ f.acceptLogin("cred-1", 1)
421+
422+ var ids []string
423+ for range usecase.MaxPendingPasskeyLogins {
424+ id, _, err := f.passkeys.BeginLogin(ctx)
425+ require.NoError(t, err)
426+ ids = append(ids, id)
427+ f.advance(time.Millisecond) // the order of creation is the order of time too
428+ }
429+ newest, _, err := f.passkeys.BeginLogin(ctx)
430+ require.NoError(t, err, "the 257th is not refused")
431+
432+ _, _, err = f.passkeys.FinishLogin(ctx, ids[0], []byte(`{}`))
433+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the oldest was evicted")
434+ _, _, err = f.passkeys.FinishLogin(ctx, ids[1], []byte(`{}`))
435+ assert.NoError(t, err, "the second oldest is still there")
436+ _, _, err = f.passkeys.FinishLogin(ctx, newest, []byte(`{}`))
437+ assert.NoError(t, err, "the newest works")
438+}
439+
440+// Logins never push out a registration, and registrations never push out a login.
441+func TestCeremonyKindsAreSeparate(t *testing.T) {
442+ t.Parallel()
443+ ctx := context.Background()
444+ f := newPasskeyFixture(t)
445+ f.add("cred-1")
446+ f.acceptLogin("cred-1", 1)
447+ f.acceptRegistration("cred-2")
448+
449+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
450+ require.NoError(t, err)
451+ login, _, err := f.passkeys.BeginLogin(ctx)
452+ require.NoError(t, err)
453+
454+ // A registration ceremony does not finish a login, nor the reverse; the
455+ // attempt burns it.
456+ _, _, err = f.passkeys.FinishLogin(ctx, reg, []byte(`{}`))
457+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
458+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), login, "x", []byte(`{}`))
459+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
460+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
461+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "burned by the mismatch")
462+ assert.Equal(t, 1, f.count(), "no passkey was added")
463+}
464+
465+func TestUnknownCeremony(t *testing.T) {
466+ t.Parallel()
467+ ctx := context.Background()
468+ f := newPasskeyFixture(t)
469+ _, _, err := f.passkeys.FinishLogin(ctx, "", []byte(`{}`))
470+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
471+ _, _, err = f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
472+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
473+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "nope", "x", []byte(`{}`))
474+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
475+}
476+
477+// A passkey login gives a session that Verify accepts, and records the use.
478+func TestLoginIssuesASession(t *testing.T) {
479+ t.Parallel()
480+ ctx := context.Background()
481+ f := newPasskeyFixture(t)
482+ f.add("cred-1")
483+ f.acceptLogin("cred-1", 9)
484+
485+ id, _, err := f.passkeys.BeginLogin(ctx)
486+ require.NoError(t, err)
487+ f.advance(10 * time.Second)
488+ token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
489+ require.NoError(t, err)
490+ assert.Equal(t, f.at.Load().Add(usecase.SessionTTL).Truncate(time.Second), exp)
491+
492+ p, err := f.sessions.Verify(ctx, token)
493+ require.NoError(t, err)
494+ assert.Equal(t, "owner", p.Label())
495+ assert.True(t, p.HasOwnerRights())
496+
497+ list, err := f.passkeys.List(ctx, domain.Owner())
498+ require.NoError(t, err)
499+ require.Len(t, list, 1)
500+ assert.Equal(t, uint32(9), list[0].SignCount)
501+ assert.True(t, list[0].BackupState)
502+ assert.Equal(t, *f.at.Load(), list[0].LastUsedAt)
503+
504+ // The ceremony is single use.
505+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
506+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
507+}
508+
509+func TestLoginRefusals(t *testing.T) {
510+ t.Parallel()
511+ ctx := context.Background()
512+
513+ t.Run("unknown passkey", func(t *testing.T) {
514+ t.Parallel()
515+ f := newPasskeyFixture(t)
516+ f.acceptLogin("ghost", 1)
517+ id, _, err := f.passkeys.BeginLogin(ctx)
518+ require.NoError(t, err)
519+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
520+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
521+ })
522+
523+ t.Run("verifier refuses", func(t *testing.T) {
524+ t.Parallel()
525+ f := newPasskeyFixture(t)
526+ f.add("cred-1")
527+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
528+ Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
529+ id, _, err := f.passkeys.BeginLogin(ctx)
530+ require.NoError(t, err)
531+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
532+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
533+ assert.NotContains(t, err.Error(), "bad signature", "the reason is for logs, not for the caller")
534+ })
535+
536+ t.Run("cloned authenticator", func(t *testing.T) {
537+ t.Parallel()
538+ f := newPasskeyFixture(t)
539+ p := f.add("cred-1")
540+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
541+ Return(domain.Passkey{}, domain.ErrPasskeyCloned)
542+ id, _, err := f.passkeys.BeginLogin(ctx)
543+ require.NoError(t, err)
544+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
545+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
546+ got, err := f.passkeys.List(ctx, domain.Owner())
547+ require.NoError(t, err)
548+ assert.Equal(t, []domain.Passkey{p}, got, "nothing recorded")
549+ })
550+
551+ t.Run("store failure fails closed and is not an authentication error", func(t *testing.T) {
552+ t.Parallel()
553+ f := newPasskeyFixture(t)
554+ boom := errors.New("disk on fire")
555+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
556+ Return(domain.Passkey{}, boom)
557+ id, _, err := f.passkeys.BeginLogin(ctx)
558+ require.NoError(t, err)
559+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
560+ require.ErrorIs(t, err, boom)
561+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
562+ assert.Empty(t, token)
563+ })
564+
565+ t.Run("update failure gives no session", func(t *testing.T) {
566+ t.Parallel()
567+ f := newPasskeyFixture(t)
568+ store := mocks.NewPasskeyStore(t)
569+ boom := errors.New("disk on fire")
570+ store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
571+ store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
572+ verifier := mocks.NewPasskeyVerifier(t)
573+ verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
574+ verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
575+ Return(domain.Passkey{ID: []byte("cred-1")}, nil)
576+ pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
577+ id, _, err := pk.BeginLogin(ctx)
578+ require.NoError(t, err)
579+ token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
580+ require.ErrorIs(t, err, boom)
581+ assert.Empty(t, token)
582+ })
583+
584+ t.Run("removed while verifying", func(t *testing.T) {
585+ t.Parallel()
586+ f := newPasskeyFixture(t)
587+ f.add("cred-1")
588+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
589+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
590+ p, err := lookup([]byte("cred-1"))
591+ require.NoError(t, err)
592+ f.mu.Lock()
593+ delete(f.stored, "cred-1") // an owner removes it meanwhile
594+ f.mu.Unlock()
595+ return p, nil
596+ })
597+ id, _, err := f.passkeys.BeginLogin(ctx)
598+ require.NoError(t, err)
599+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
600+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
601+ assert.Empty(t, token)
602+ })
603+}
604+
605+// Removing a passkey signs every browser out, the passkey's own sessions
606+// included.
607+func TestRemoveBumpsTheEpoch(t *testing.T) {
608+ t.Parallel()
609+ ctx := context.Background()
610+ f := newPasskeyFixture(t)
611+ f.add("cred-1")
612+ f.add("cred-2")
613+ f.acceptLogin("cred-1", 1)
614+
615+ id, _, err := f.passkeys.BeginLogin(ctx)
616+ require.NoError(t, err)
617+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
618+ require.NoError(t, err)
619+ _, err = f.sessions.Verify(ctx, token)
620+ require.NoError(t, err)
621+ linkToken := func() string {
622+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
623+ require.NoError(t, err)
624+ return tok
625+ }()
626+
627+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("cred-2")))
628+ assert.Equal(t, 1, f.count())
629+ for name, tok := range map[string]string{"passkey session": token, "link session": linkToken} {
630+ _, err = f.sessions.Verify(ctx, tok)
631+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
632+ }
633+
634+ // A new login works with the epoch it finds now.
635+ id, _, err = f.passkeys.BeginLogin(ctx)
636+ require.NoError(t, err)
637+ token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
638+ require.NoError(t, err)
639+ _, err = f.sessions.Verify(ctx, token)
640+ assert.NoError(t, err)
641+}
642+
643+func TestRemoveUnknownKeepsTheSessions(t *testing.T) {
644+ t.Parallel()
645+ ctx := context.Background()
646+ f := newPasskeyFixture(t)
647+ f.add("cred-1")
648+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
649+ require.NoError(t, err)
650+
651+ err = f.passkeys.Remove(ctx, domain.Owner(), []byte("ghost"))
652+ require.ErrorIs(t, err, domain.ErrNotFound)
653+ _, err = f.sessions.Verify(ctx, tok)
654+ assert.NoError(t, err, "nothing was removed, nobody is signed out")
655+}
656+
657+func TestRemoveReportsASignOutFailure(t *testing.T) {
658+ t.Parallel()
659+ ctx := context.Background()
660+ boom := errors.New("disk on fire")
661+ sessionStore := mocks.NewSessionStore(t)
662+ sessionStore.EXPECT().Key(mock.Anything).Return(testHandle, nil)
663+ sessionStore.EXPECT().BumpEpoch(mock.Anything).Return(0, boom)
664+ clock := mocks.NewClock(t)
665+ clock.EXPECT().Now().Return(time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)).Maybe()
666+ sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
667+ require.NoError(t, err)
668+
669+ store := mocks.NewPasskeyStore(t)
670+ store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(nil)
671+ pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), sessions, clock)
672+ err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
673+ require.ErrorIs(t, err, boom)
674+ assert.Contains(t, err.Error(), "signing out the web sessions failed")
675+}
676+
677+// The use cases are safe for concurrent callers (run with -race).
678+func TestPasskeysConcurrent(t *testing.T) {
679+ t.Parallel()
680+ ctx := context.Background()
681+ f := newPasskeyFixture(t)
682+ f.add("cred-1")
683+ f.acceptLogin("cred-1", 1)
684+ var wg sync.WaitGroup
685+ for range 32 {
686+ wg.Add(1)
687+ go func() {
688+ defer wg.Done()
689+ id, _, err := f.passkeys.BeginLogin(ctx)
690+ if assert.NoError(t, err) {
691+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
692+ assert.NoError(t, err)
693+ }
694+ _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner())
695+ }()
696+ }
697+ wg.Wait()
698+}
@@ -0,0 +1,698 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "slices"
11+ "sync"
12+ "sync/atomic"
13+ "testing"
14+ "time"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+var testHandle = []byte("0123456789abcdef0123456789abcdef")
26+
27+type passkeyFixture struct {
28+ *sessionFixture
29+ store *mocks.PasskeyStore
30+ verifier *mocks.PasskeyVerifier
31+ passkeys *usecase.Passkeys
32+
33+ mu sync.Mutex
34+ stored map[string]domain.Passkey // by credential id
35+ ceremon atomic.Int64 // makes the ceremony data of each begin unique
36+}
37+
38+func newPasskeyFixture(t *testing.T) *passkeyFixture {
39+ t.Helper()
40+ f := &passkeyFixture{
41+ sessionFixture: newSessionFixture(t),
42+ store: mocks.NewPasskeyStore(t),
43+ verifier: mocks.NewPasskeyVerifier(t),
44+ stored: map[string]domain.Passkey{},
45+ }
46+ f.store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil).Maybe()
47+ f.store.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Passkey, error) {
48+ f.mu.Lock()
49+ defer f.mu.Unlock()
50+ var out []domain.Passkey
51+ for _, p := range f.stored {
52+ out = append(out, p)
53+ }
54+ slices.SortFunc(out, func(a, b domain.Passkey) int { return bytes.Compare(a.ID, b.ID) })
55+ return out, nil
56+ }).Maybe()
57+ f.store.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) (domain.Passkey, error) {
58+ f.mu.Lock()
59+ defer f.mu.Unlock()
60+ if p, ok := f.stored[string(id)]; ok {
61+ return p, nil
62+ }
63+ return domain.Passkey{}, domain.ErrNotFound
64+ }).Maybe()
65+ f.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
66+ f.mu.Lock()
67+ defer f.mu.Unlock()
68+ if _, ok := f.stored[string(p.ID)]; ok {
69+ return domain.ErrAlreadyExists
70+ }
71+ f.stored[string(p.ID)] = p
72+ return nil
73+ }).Maybe()
74+ f.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
75+ f.mu.Lock()
76+ defer f.mu.Unlock()
77+ if _, ok := f.stored[string(p.ID)]; !ok {
78+ return domain.ErrNotFound
79+ }
80+ f.stored[string(p.ID)] = p
81+ return nil
82+ }).Maybe()
83+ f.store.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) error {
84+ f.mu.Lock()
85+ defer f.mu.Unlock()
86+ if _, ok := f.stored[string(id)]; !ok {
87+ return domain.ErrNotFound
88+ }
89+ delete(f.stored, string(id))
90+ return nil
91+ }).Maybe()
92+
93+ f.verifier.EXPECT().BeginRegistration(mock.Anything, mock.Anything).RunAndReturn(func([]byte, []domain.Passkey) ([]byte, []byte, error) {
94+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("register-%d", f.ceremon.Add(1))), nil
95+ }).Maybe()
96+ f.verifier.EXPECT().BeginLogin().RunAndReturn(func() ([]byte, []byte, error) {
97+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("login-%d", f.ceremon.Add(1))), nil
98+ }).Maybe()
99+
100+ f.passkeys = usecase.NewPasskeys(f.store, f.verifier, f.sessions, f.clock)
101+ return f
102+}
103+
104+// add puts a passkey in the store directly.
105+func (f *passkeyFixture) add(id string) domain.Passkey {
106+ p := domain.Passkey{ID: []byte(id), Name: id, PublicKey: []byte("k"), CreatedAt: *f.at.Load()}
107+ f.mu.Lock()
108+ f.stored[id] = p
109+ f.mu.Unlock()
110+ return p
111+}
112+
113+func (f *passkeyFixture) count() int {
114+ f.mu.Lock()
115+ defer f.mu.Unlock()
116+ return len(f.stored)
117+}
118+
119+// acceptRegistration makes the verifier accept any registration as the passkey.
120+func (f *passkeyFixture) acceptRegistration(id string) {
121+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(func(_, ceremony, _ []byte) (domain.Passkey, error) {
122+ if !bytes.HasPrefix(ceremony, []byte("register-")) {
123+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
124+ }
125+ return domain.Passkey{ID: []byte(id), PublicKey: []byte("pub"), AttestationType: "none", Transports: []string{"internal"}, BackupEligible: true}, nil
126+ }).Maybe()
127+}
128+
129+// acceptLogin makes the verifier accept a login with the stored passkey `id`,
130+// reporting the sign count.
131+func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {
132+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133+ func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
134+ if !bytes.HasPrefix(ceremony, []byte("login-")) {
135+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
136+ }
137+ p, err := lookup([]byte(id))
138+ if err != nil {
139+ return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
140+ }
141+ p.SignCount = signCount
142+ p.BackupState = true
143+ return p, nil
144+ }).Maybe()
145+}
146+
147+func ownerAndOthers() map[string]struct {
148+ p domain.Principal
149+ err error
150+} {
151+ partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
152+ return map[string]struct {
153+ p domain.Principal
154+ err error
155+ }{
156+ "root": {domain.Root(), nil},
157+ "owner session": {domain.Owner(), nil},
158+ "every scope": {allScopeToken(), nil},
159+ "scoped token": {partial, domain.ErrForbidden},
160+ "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
161+ }
162+}
163+
164+func TestPasskeysAreOwnerOnly(t *testing.T) {
165+ t.Parallel()
166+ ctx := context.Background()
167+ for name, tc := range ownerAndOthers() {
168+ t.Run(name, func(t *testing.T) {
169+ t.Parallel()
170+ f := newPasskeyFixture(t)
171+ f.add("a")
172+ f.acceptRegistration("new")
173+ check := func(what string, err error) {
174+ t.Helper()
175+ if tc.err == nil {
176+ assert.NoError(t, err, what)
177+ } else {
178+ assert.ErrorIs(t, err, tc.err, what)
179+ }
180+ }
181+ _, _, err := f.passkeys.BeginRegistration(ctx, tc.p)
182+ check("begin registration", err)
183+ _, err = f.passkeys.List(ctx, tc.p)
184+ check("list", err)
185+
186+ // A ceremony begun by the owner cannot be finished by anyone else.
187+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
188+ require.NoError(t, err)
189+ _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}"))
190+ check("finish registration", err)
191+ if tc.err != nil {
192+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "n", []byte("{}"))
193+ assert.NoError(t, err, "a refused caller does not burn the ceremony")
194+ }
195+
196+ err = f.passkeys.Remove(ctx, tc.p, []byte("a"))
197+ check("remove", err)
198+ if tc.err != nil {
199+ assert.Equal(t, 2, f.count(), "nothing removed")
200+ }
201+ })
202+ }
203+}
204+
205+func TestBeginLoginIsAnonymous(t *testing.T) {
206+ t.Parallel()
207+ f := newPasskeyFixture(t)
208+ id, options, err := f.passkeys.BeginLogin(context.Background())
209+ require.NoError(t, err)
210+ assert.Len(t, id, 43, "256 bits")
211+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
212+}
213+
214+func TestRegistration(t *testing.T) {
215+ t.Parallel()
216+ ctx := context.Background()
217+ f := newPasskeyFixture(t)
218+ f.acceptRegistration("cred-1")
219+
220+ id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
221+ require.NoError(t, err)
222+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
223+
224+ got, err := f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "Pixel 9", []byte(`{}`))
225+ require.NoError(t, err)
226+ assert.Equal(t, "Pixel 9", got.Name)
227+ assert.Equal(t, *f.at.Load(), got.CreatedAt)
228+ assert.True(t, got.LastUsedAt.IsZero())
229+ assert.Equal(t, []byte("cred-1"), got.ID)
230+ list, err := f.passkeys.List(ctx, domain.Owner())
231+ require.NoError(t, err)
232+ require.Len(t, list, 1)
233+ assert.Equal(t, got, list[0])
234+
235+ // The ceremony is single use.
236+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "again", []byte(`{}`))
237+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
238+}
239+
240+func TestRegistrationDefaultNameAndBadName(t *testing.T) {
241+ t.Parallel()
242+ ctx := context.Background()
243+ f := newPasskeyFixture(t)
244+ f.acceptRegistration("cred-1")
245+
246+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
247+ require.NoError(t, err)
248+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`))
249+ require.ErrorIs(t, err, domain.ErrInvalidText)
250+ assert.Zero(t, f.count())
251+
252+ // The verifier was not asked, and the ceremony is still there for a retry.
253+ got, err := f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "", []byte(`{}`))
254+ require.NoError(t, err)
255+ assert.Equal(t, domain.DefaultPasskeyName, got.Name)
256+}
257+
258+func TestRegistrationDuplicate(t *testing.T) {
259+ t.Parallel()
260+ ctx := context.Background()
261+ f := newPasskeyFixture(t)
262+ f.add("cred-1")
263+ f.acceptRegistration("cred-1")
264+
265+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
266+ require.NoError(t, err)
267+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`))
268+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
269+ assert.Equal(t, 1, f.count())
270+}
271+
272+func TestRegistrationRefusedByVerifier(t *testing.T) {
273+ t.Parallel()
274+ ctx := context.Background()
275+ f := newPasskeyFixture(t)
276+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
277+ Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
278+
279+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
280+ require.NoError(t, err)
281+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
282+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
283+ assert.Zero(t, f.count())
284+}
285+
286+func TestRegistrationRefusesAnIdTooLong(t *testing.T) {
287+ t.Parallel()
288+ ctx := context.Background()
289+ f := newPasskeyFixture(t)
290+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
291+ Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
292+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
293+ require.NoError(t, err)
294+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
295+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
296+ assert.Zero(t, f.count())
297+}
298+
299+// The verifier is told about the passkeys to exclude.
300+func TestBeginRegistrationExcludesExisting(t *testing.T) {
301+ t.Parallel()
302+ f := newPasskeyFixture(t)
303+ f.add("a")
304+ f.add("b")
305+ f.verifier.ExpectedCalls = nil // replace the permissive default
306+ f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
307+ return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
308+ })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
309+ _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner())
310+ require.NoError(t, err)
311+}
312+
313+func TestMaxPasskeys(t *testing.T) {
314+ t.Parallel()
315+ ctx := context.Background()
316+ f := newPasskeyFixture(t)
317+ f.acceptRegistration("one-more")
318+ for i := range domain.MaxPasskeys - 1 {
319+ f.add(fmt.Sprintf("p%02d", i))
320+ }
321+ // A ceremony begun with room for one more.
322+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
323+ require.NoError(t, err)
324+ f.add("last")
325+ assert.Equal(t, domain.MaxPasskeys, f.count())
326+
327+ // Full: no new ceremony, and the one in flight is refused at the end.
328+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
329+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
330+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
331+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
332+ assert.Equal(t, domain.MaxPasskeys, f.count())
333+
334+ // Removing one makes room again.
335+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
336+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
337+ assert.NoError(t, err)
338+}
339+
340+func TestCeremonyExpiry(t *testing.T) {
341+ t.Parallel()
342+ ctx := context.Background()
343+ f := newPasskeyFixture(t)
344+ f.acceptRegistration("cred-1")
345+ f.add("login-cred")
346+ f.acceptLogin("login-cred", 1)
347+
348+ // One second short of the TTL still works.
349+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
350+ require.NoError(t, err)
351+ login, _, err := f.passkeys.BeginLogin(ctx)
352+ require.NoError(t, err)
353+ f.advance(usecase.PasskeyCeremonyTTL - time.Second)
354+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
355+ assert.NoError(t, err)
356+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
357+ assert.NoError(t, err)
358+
359+ // At the TTL it is gone, and it stays gone.
360+ reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
361+ require.NoError(t, err)
362+ login, _, err = f.passkeys.BeginLogin(ctx)
363+ require.NoError(t, err)
364+ assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL)
365+ f.advance(usecase.PasskeyCeremonyTTL)
366+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
367+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
368+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
369+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
370+}
371+
372+// Expired ceremonies are swept on begin, so they stop counting.
373+func TestExpiredRegistrationsDoNotCount(t *testing.T) {
374+ t.Parallel()
375+ ctx := context.Background()
376+ f := newPasskeyFixture(t)
377+ for range usecase.MaxPendingPasskeyRegistrations {
378+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
379+ require.NoError(t, err)
380+ }
381+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
382+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
383+ f.advance(usecase.PasskeyCeremonyTTL)
384+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
385+ assert.NoError(t, err)
386+}
387+
388+func TestPendingRegistrationsAreBounded(t *testing.T) {
389+ t.Parallel()
390+ ctx := context.Background()
391+ f := newPasskeyFixture(t)
392+ f.acceptRegistration("cred-1")
393+ var ids []string
394+ for range usecase.MaxPendingPasskeyRegistrations {
395+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
396+ require.NoError(t, err)
397+ ids = append(ids, id)
398+ }
399+ assert.Len(t, ids, 16)
400+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
401+ require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
402+
403+ // Pending logins do not use the registrations' room, and a finished
404+ // registration frees one.
405+ _, _, err = f.passkeys.BeginLogin(ctx)
406+ require.NoError(t, err)
407+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`))
408+ require.NoError(t, err)
409+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
410+ assert.NoError(t, err)
411+}
412+
413+// Anonymous callers cannot fill the room: the 257th pending login evicts the
414+// oldest, which then fails; the newest works.
415+func TestPendingLoginsEvictTheOldest(t *testing.T) {
416+ t.Parallel()
417+ ctx := context.Background()
418+ f := newPasskeyFixture(t)
419+ f.add("cred-1")
420+ f.acceptLogin("cred-1", 1)
421+
422+ var ids []string
423+ for range usecase.MaxPendingPasskeyLogins {
424+ id, _, err := f.passkeys.BeginLogin(ctx)
425+ require.NoError(t, err)
426+ ids = append(ids, id)
427+ f.advance(time.Millisecond) // the order of creation is the order of time too
428+ }
429+ newest, _, err := f.passkeys.BeginLogin(ctx)
430+ require.NoError(t, err, "the 257th is not refused")
431+
432+ _, _, err = f.passkeys.FinishLogin(ctx, ids[0], []byte(`{}`))
433+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the oldest was evicted")
434+ _, _, err = f.passkeys.FinishLogin(ctx, ids[1], []byte(`{}`))
435+ assert.NoError(t, err, "the second oldest is still there")
436+ _, _, err = f.passkeys.FinishLogin(ctx, newest, []byte(`{}`))
437+ assert.NoError(t, err, "the newest works")
438+}
439+
440+// Logins never push out a registration, and registrations never push out a login.
441+func TestCeremonyKindsAreSeparate(t *testing.T) {
442+ t.Parallel()
443+ ctx := context.Background()
444+ f := newPasskeyFixture(t)
445+ f.add("cred-1")
446+ f.acceptLogin("cred-1", 1)
447+ f.acceptRegistration("cred-2")
448+
449+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
450+ require.NoError(t, err)
451+ login, _, err := f.passkeys.BeginLogin(ctx)
452+ require.NoError(t, err)
453+
454+ // A registration ceremony does not finish a login, nor the reverse; the
455+ // attempt burns it.
456+ _, _, err = f.passkeys.FinishLogin(ctx, reg, []byte(`{}`))
457+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
458+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), login, "x", []byte(`{}`))
459+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
460+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
461+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "burned by the mismatch")
462+ assert.Equal(t, 1, f.count(), "no passkey was added")
463+}
464+
465+func TestUnknownCeremony(t *testing.T) {
466+ t.Parallel()
467+ ctx := context.Background()
468+ f := newPasskeyFixture(t)
469+ _, _, err := f.passkeys.FinishLogin(ctx, "", []byte(`{}`))
470+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
471+ _, _, err = f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
472+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
473+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "nope", "x", []byte(`{}`))
474+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
475+}
476+
477+// A passkey login gives a session that Verify accepts, and records the use.
478+func TestLoginIssuesASession(t *testing.T) {
479+ t.Parallel()
480+ ctx := context.Background()
481+ f := newPasskeyFixture(t)
482+ f.add("cred-1")
483+ f.acceptLogin("cred-1", 9)
484+
485+ id, _, err := f.passkeys.BeginLogin(ctx)
486+ require.NoError(t, err)
487+ f.advance(10 * time.Second)
488+ token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
489+ require.NoError(t, err)
490+ assert.Equal(t, f.at.Load().Add(usecase.SessionTTL).Truncate(time.Second), exp)
491+
492+ p, err := f.sessions.Verify(ctx, token)
493+ require.NoError(t, err)
494+ assert.Equal(t, "owner", p.Label())
495+ assert.True(t, p.HasOwnerRights())
496+
497+ list, err := f.passkeys.List(ctx, domain.Owner())
498+ require.NoError(t, err)
499+ require.Len(t, list, 1)
500+ assert.Equal(t, uint32(9), list[0].SignCount)
501+ assert.True(t, list[0].BackupState)
502+ assert.Equal(t, *f.at.Load(), list[0].LastUsedAt)
503+
504+ // The ceremony is single use.
505+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
506+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
507+}
508+
509+func TestLoginRefusals(t *testing.T) {
510+ t.Parallel()
511+ ctx := context.Background()
512+
513+ t.Run("unknown passkey", func(t *testing.T) {
514+ t.Parallel()
515+ f := newPasskeyFixture(t)
516+ f.acceptLogin("ghost", 1)
517+ id, _, err := f.passkeys.BeginLogin(ctx)
518+ require.NoError(t, err)
519+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
520+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
521+ })
522+
523+ t.Run("verifier refuses", func(t *testing.T) {
524+ t.Parallel()
525+ f := newPasskeyFixture(t)
526+ f.add("cred-1")
527+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
528+ Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
529+ id, _, err := f.passkeys.BeginLogin(ctx)
530+ require.NoError(t, err)
531+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
532+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
533+ assert.NotContains(t, err.Error(), "bad signature", "the reason is for logs, not for the caller")
534+ })
535+
536+ t.Run("cloned authenticator", func(t *testing.T) {
537+ t.Parallel()
538+ f := newPasskeyFixture(t)
539+ p := f.add("cred-1")
540+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
541+ Return(domain.Passkey{}, domain.ErrPasskeyCloned)
542+ id, _, err := f.passkeys.BeginLogin(ctx)
543+ require.NoError(t, err)
544+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
545+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
546+ got, err := f.passkeys.List(ctx, domain.Owner())
547+ require.NoError(t, err)
548+ assert.Equal(t, []domain.Passkey{p}, got, "nothing recorded")
549+ })
550+
551+ t.Run("store failure fails closed and is not an authentication error", func(t *testing.T) {
552+ t.Parallel()
553+ f := newPasskeyFixture(t)
554+ boom := errors.New("disk on fire")
555+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
556+ Return(domain.Passkey{}, boom)
557+ id, _, err := f.passkeys.BeginLogin(ctx)
558+ require.NoError(t, err)
559+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
560+ require.ErrorIs(t, err, boom)
561+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
562+ assert.Empty(t, token)
563+ })
564+
565+ t.Run("update failure gives no session", func(t *testing.T) {
566+ t.Parallel()
567+ f := newPasskeyFixture(t)
568+ store := mocks.NewPasskeyStore(t)
569+ boom := errors.New("disk on fire")
570+ store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
571+ store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
572+ verifier := mocks.NewPasskeyVerifier(t)
573+ verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
574+ verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
575+ Return(domain.Passkey{ID: []byte("cred-1")}, nil)
576+ pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
577+ id, _, err := pk.BeginLogin(ctx)
578+ require.NoError(t, err)
579+ token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
580+ require.ErrorIs(t, err, boom)
581+ assert.Empty(t, token)
582+ })
583+
584+ t.Run("removed while verifying", func(t *testing.T) {
585+ t.Parallel()
586+ f := newPasskeyFixture(t)
587+ f.add("cred-1")
588+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
589+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
590+ p, err := lookup([]byte("cred-1"))
591+ require.NoError(t, err)
592+ f.mu.Lock()
593+ delete(f.stored, "cred-1") // an owner removes it meanwhile
594+ f.mu.Unlock()
595+ return p, nil
596+ })
597+ id, _, err := f.passkeys.BeginLogin(ctx)
598+ require.NoError(t, err)
599+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
600+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
601+ assert.Empty(t, token)
602+ })
603+}
604+
605+// Removing a passkey signs every browser out, the passkey's own sessions
606+// included.
607+func TestRemoveBumpsTheEpoch(t *testing.T) {
608+ t.Parallel()
609+ ctx := context.Background()
610+ f := newPasskeyFixture(t)
611+ f.add("cred-1")
612+ f.add("cred-2")
613+ f.acceptLogin("cred-1", 1)
614+
615+ id, _, err := f.passkeys.BeginLogin(ctx)
616+ require.NoError(t, err)
617+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
618+ require.NoError(t, err)
619+ _, err = f.sessions.Verify(ctx, token)
620+ require.NoError(t, err)
621+ linkToken := func() string {
622+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
623+ require.NoError(t, err)
624+ return tok
625+ }()
626+
627+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("cred-2")))
628+ assert.Equal(t, 1, f.count())
629+ for name, tok := range map[string]string{"passkey session": token, "link session": linkToken} {
630+ _, err = f.sessions.Verify(ctx, tok)
631+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
632+ }
633+
634+ // A new login works with the epoch it finds now.
635+ id, _, err = f.passkeys.BeginLogin(ctx)
636+ require.NoError(t, err)
637+ token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
638+ require.NoError(t, err)
639+ _, err = f.sessions.Verify(ctx, token)
640+ assert.NoError(t, err)
641+}
642+
643+func TestRemoveUnknownKeepsTheSessions(t *testing.T) {
644+ t.Parallel()
645+ ctx := context.Background()
646+ f := newPasskeyFixture(t)
647+ f.add("cred-1")
648+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
649+ require.NoError(t, err)
650+
651+ err = f.passkeys.Remove(ctx, domain.Owner(), []byte("ghost"))
652+ require.ErrorIs(t, err, domain.ErrNotFound)
653+ _, err = f.sessions.Verify(ctx, tok)
654+ assert.NoError(t, err, "nothing was removed, nobody is signed out")
655+}
656+
657+func TestRemoveReportsASignOutFailure(t *testing.T) {
658+ t.Parallel()
659+ ctx := context.Background()
660+ boom := errors.New("disk on fire")
661+ sessionStore := mocks.NewSessionStore(t)
662+ sessionStore.EXPECT().Key(mock.Anything).Return(testHandle, nil)
663+ sessionStore.EXPECT().BumpEpoch(mock.Anything).Return(0, boom)
664+ clock := mocks.NewClock(t)
665+ clock.EXPECT().Now().Return(time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)).Maybe()
666+ sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
667+ require.NoError(t, err)
668+
669+ store := mocks.NewPasskeyStore(t)
670+ store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(nil)
671+ pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), sessions, clock)
672+ err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
673+ require.ErrorIs(t, err, boom)
674+ assert.Contains(t, err.Error(), "signing out the web sessions failed")
675+}
676+
677+// The use cases are safe for concurrent callers (run with -race).
678+func TestPasskeysConcurrent(t *testing.T) {
679+ t.Parallel()
680+ ctx := context.Background()
681+ f := newPasskeyFixture(t)
682+ f.add("cred-1")
683+ f.acceptLogin("cred-1", 1)
684+ var wg sync.WaitGroup
685+ for range 32 {
686+ wg.Add(1)
687+ go func() {
688+ defer wg.Done()
689+ id, _, err := f.passkeys.BeginLogin(ctx)
690+ if assert.NoError(t, err) {
691+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
692+ assert.NoError(t, err)
693+ }
694+ _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner())
695+ }()
696+ }
697+ wg.Wait()
698+}
M internal/usecase/sessions.go +11 -0
@@ -189,6 +189,17 @@ func (s *Sessions) issue(ctx context.Context, capAt time.Time) (string, time.Tim
189189 return tok, exp.Truncate(time.Second), nil
190190 }
191191
192+// issueOwner opens an uncapped session for the owner (a passkey login): the
193+// same token a login link gives, for SessionTTL.
194+func (s *Sessions) issueOwner(ctx context.Context) (string, time.Time, error) {
195+ return s.issue(ctx, time.Time{})
196+}
197+
198+// bumpEpoch ends every session and returns the new epoch.
199+func (s *Sessions) bumpEpoch(ctx context.Context) (uint64, error) {
200+ return s.store.BumpEpoch(ctx)
201+}
202+
192203 // Verify checks a session token and returns the owner. The signature must be
193204 // HS256 with the session key (the algorithm is pinned, so "none" and any other
194205 // are refused), the token must not be expired or issued in the future, and its
@@ -189,6 +189,17 @@ func (s *Sessions) issue(ctx context.Context, capAt time.Time) (string, time.Tim
189 return tok, exp.Truncate(time.Second), nil189 return tok, exp.Truncate(time.Second), nil
190 }190 }
191 191
192+// issueOwner opens an uncapped session for the owner (a passkey login): the
193+// same token a login link gives, for SessionTTL.
194+func (s *Sessions) issueOwner(ctx context.Context) (string, time.Time, error) {
195+ return s.issue(ctx, time.Time{})
196+}
197+
198+// bumpEpoch ends every session and returns the new epoch.
199+func (s *Sessions) bumpEpoch(ctx context.Context) (uint64, error) {
200+ return s.store.BumpEpoch(ctx)
201+}
202+
192 // Verify checks a session token and returns the owner. The signature must be203 // Verify checks a session token and returns the owner. The signature must be
193 // HS256 with the session key (the algorithm is pinned, so "none" and any other204 // HS256 with the session key (the algorithm is pinned, so "none" and any other
194 // are refused), the token must not be expired or issued in the future, and its205 // are refused), the token must not be expired or issued in the future, and its

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 4

Tags 45

↑↓ moveenter openesc close