1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21 "bnema.dev/ermite/internal/ports/mocks"
22)
23
24const forgeOrigin = "https://forge.example"
25
26// blockingLimiter is a FailureLimiter that can be told to block.
27type blockingLimiter struct {
28 blocked bool
29 fails int
30}
31
32func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33 if l.blocked {
34 return true, 90 * time.Second
35 }
36 return false, 0
37}
38func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39
40type passkeyFixture struct {
41 srv *Server
42 sessions *mocks.WebSessionService
43 passkeys *mocks.WebPasskeyService
44 lim *blockingLimiter
45}
46
47func newPasskeyServer(t *testing.T) *passkeyFixture {
48 t.Helper()
49 f := &passkeyFixture{
50 sessions: newSessions(t, "tok"),
51 passkeys: mocks.NewWebPasskeyService(t),
52 lim: &blockingLimiter{},
53 }
54 f.srv = newTestServer(t, Config{
55 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56 PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57 Now: func() time.Time { return fixedNow },
58 })
59 return f
60}
61
62// fixedNow is the clock of the passkey pages: "3d ago" does not depend on when
63// the test runs.
64var fixedNow = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
65
66type req struct {
67 method, target string
68 origin string // "" sends none
69 cookie string
70 contentType string
71 body string
72}
73
74func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
75 t.Helper()
76 hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
77 hr.Host = "forge.example"
78 if r.origin != "" {
79 hr.Header.Set("Origin", r.origin)
80 }
81 if r.cookie != "" {
82 hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
83 }
84 if r.contentType != "" {
85 hr.Header.Set("Content-Type", r.contentType)
86 }
87 rec := httptest.NewRecorder()
88 f.srv.ServeHTTP(rec, hr)
89 return rec
90}
91
92func postJSON(target, cookie, body string) req {
93 return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
94}
95
96const finishBody = `{"ceremony":"c1","credential":{"id":"x","type":"public-key"}}`
97
98var (
99 beginLogin = "/-/passkeys/login/begin"
100 finishLogin = "/-/passkeys/login/finish"
101 beginRegister = "/-/passkeys/register/begin"
102 finishRegister = "/-/passkeys/register/finish"
103)
104
105// Every new POST needs the Origin of the public URL.
106func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
107 t.Parallel()
108 f := newPasskeyServer(t) // the mocks fail the test on any call
109 removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
110 for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
111 for _, cookie := range []string{"", "tok"} {
112 for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
113 rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
114 assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
115 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
116 }
117 }
118 }
119}
120
121// An anonymous browser may post to the two login endpoints only.
122func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
123 t.Parallel()
124 f := newPasskeyServer(t)
125 id := base64.RawURLEncoding.EncodeToString([]byte("a"))
126 for _, target := range []string{
127 beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
128 "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
129 "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
130 "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
131 "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
132 } {
133 rec := f.do(t, postJSON(target, "", finishBody))
134 assert.Equal(t, http.StatusForbidden, rec.Code, target)
135 }
136 // A GET is never a login.
137 rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
138 assert.NotEqual(t, http.StatusOK, rec.Code)
139}
140
141func TestPasskeyLoginBegin(t *testing.T) {
142 t.Parallel()
143 f := newPasskeyServer(t)
144 f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
145
146 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
147 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
148 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
149 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
150 assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
151 assert.Empty(t, rec.Header().Values("Set-Cookie"))
152}
153
154func TestPasskeyLoginBeginFailure(t *testing.T) {
155 t.Parallel()
156 f := newPasskeyServer(t)
157 f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once()
158 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
159 assert.Equal(t, http.StatusInternalServerError, rec.Code)
160 assert.NotContains(t, rec.Body.String(), "secret detail")
161 assert.Contains(t, rec.Body.String(), "request id")
162}
163
164func TestPasskeyLoginBlocked(t *testing.T) {
165 t.Parallel()
166 f := newPasskeyServer(t) // no use case call is expected
167 f.lim.blocked = true
168 // Blocked answers 429 whatever the request holds: the limiter comes before
169 // the content type and the body are looked at.
170 for _, r := range []req{
171 {method: "POST", target: beginLogin, origin: forgeOrigin},
172 postJSON(finishLogin, "", finishBody),
173 {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
174 postJSON(finishLogin, "", `not json`),
175 postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
176 } {
177 rec := f.do(t, r)
178 assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%s %q", r.target, r.contentType)
179 assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
180 assert.Equal(t, "91", rec.Header().Get("Retry-After"))
181 assert.Empty(t, rec.Header().Values("Set-Cookie"))
182 }
183 assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
184}
185
186func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
187 t.Parallel()
188 f := newPasskeyServer(t)
189 f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
190 return string(b) == `{"id":"x","type":"public-key"}`
191 })).Return("tok", 12*time.Hour, nil).Once()
192
193 rec := f.do(t, postJSON(finishLogin, "", finishBody))
194 require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
195 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
196 assert.Zero(t, f.lim.fails)
197
198 cookies := rec.Result().Cookies()
199 require.Len(t, cookies, 1)
200 c := cookies[0]
201 assert.Equal(t, CookieName, c.Name)
202 assert.Equal(t, "tok", c.Value)
203 assert.True(t, c.Secure)
204 assert.True(t, c.HttpOnly)
205 assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
206 assert.Equal(t, "/", c.Path)
207 assert.Equal(t, 12*3600, c.MaxAge)
208 assert.Empty(t, c.Domain)
209 set := rec.Header().Get("Set-Cookie")
210 for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
211 assert.Contains(t, set, attr)
212 }
213}
214
215// A stale cookie that withSession cleared is replaced, not doubled.
216func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
217 t.Parallel()
218 f := newPasskeyServer(t)
219 f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
220 rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
221 // A stale cookie is not a session: the login endpoint still lets it through.
222 require.Equal(t, http.StatusNoContent, rec.Code)
223 require.Len(t, rec.Result().Cookies(), 1)
224 assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
225}
226
227func TestPasskeyLoginFinishRefusal(t *testing.T) {
228 t.Parallel()
229 f := newPasskeyServer(t)
230 f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
231
232 for range 2 {
233 rec := f.do(t, postJSON(finishLogin, "", finishBody))
234 assert.Equal(t, http.StatusUnauthorized, rec.Code)
235 assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
236 assert.Empty(t, rec.Header().Values("Set-Cookie"))
237 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
238 }
239 assert.Equal(t, 2, f.lim.fails, "every refusal counts")
240}
241
242func TestPasskeyLoginFinishInternalError(t *testing.T) {
243 t.Parallel()
244 f := newPasskeyServer(t)
245 f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
246 rec := f.do(t, postJSON(finishLogin, "", finishBody))
247 assert.Equal(t, http.StatusInternalServerError, rec.Code)
248 assert.NotContains(t, rec.Body.String(), "kv down")
249 assert.Empty(t, rec.Header().Values("Set-Cookie"))
250 assert.Zero(t, f.lim.fails, "a server failure is not the client's")
251}
252
253func TestPasskeyFinishNeedsJSON(t *testing.T) {
254 t.Parallel()
255 f := newPasskeyServer(t) // no use case call is expected
256 for _, target := range []string{finishLogin, finishRegister} {
257 for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
258 cookie := ""
259 if target == finishRegister {
260 cookie = "tok"
261 }
262 rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
263 assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
264 }
265 }
266 assert.Zero(t, f.lim.fails)
267}
268
269func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
270 t.Parallel()
271 f := newPasskeyServer(t)
272 f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
273 rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
274 assert.Equal(t, http.StatusNoContent, rec.Code)
275}
276
277func TestPasskeyFinishBodyLimit(t *testing.T) {
278 t.Parallel()
279 f := newPasskeyServer(t)
280 big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
281 for _, target := range []string{finishLogin, finishRegister} {
282 rec := f.do(t, postJSON(target, "tok", big))
283 assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
284 }
285 // Right at the limit is read (and then refused as malformed, not as too large).
286 exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
287 require.Len(t, exact, 64<<10)
288 rec := f.do(t, postJSON(finishLogin, "", exact))
289 assert.Equal(t, http.StatusBadRequest, rec.Code)
290}
291
292func TestPasskeyFinishMalformed(t *testing.T) {
293 t.Parallel()
294 f := newPasskeyServer(t)
295 for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
296 for _, target := range []string{finishLogin, finishRegister} {
297 rec := f.do(t, postJSON(target, "tok", body))
298 assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
299 }
300 }
301}
302
303// The registration endpoints are the owner's.
304func TestPasskeyRegistration(t *testing.T) {
305 t.Parallel()
306 f := newPasskeyServer(t)
307 f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
308 rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
309 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
310 assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
311 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
312
313 f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", mock.MatchedBy(func(b []byte) bool {
314 return string(b) == `{"id":"x","type":"public-key"}`
315 })).Return(nil).Once()
316 rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
317 assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
318 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
319 assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
320}
321
322func TestPasskeyRegistrationErrors(t *testing.T) {
323 t.Parallel()
324 for name, tc := range map[string]struct {
325 err error
326 status int
327 body string
328 }{
329 "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
330 "limit": {domain.ErrResourceExhausted, 429, ""},
331 "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
332 "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
333 "not owner": {domain.ErrForbidden, 403, ""},
334 "server error": {errors.New("kv down"), 500, ""},
335 } {
336 t.Run(name, func(t *testing.T) {
337 t.Parallel()
338 f := newPasskeyServer(t)
339 f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
340 rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
341 assert.Equal(t, tc.status, rec.Code)
342 assert.Contains(t, rec.Body.String(), `"error"`)
343 assert.Contains(t, rec.Body.String(), tc.body)
344 assert.NotContains(t, rec.Body.String(), "kv down")
345 assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
346 })
347 }
348
349 f := newPasskeyServer(t)
350 f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
351 rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
352 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
353}
354
355func TestPasskeysPage(t *testing.T) {
356 t.Parallel()
357 f := newPasskeyServer(t)
358 now := fixedNow
359 list := []domain.Passkey{
360 {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
361 {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
362 }
363 f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
364
365 rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
366 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
367 assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
368 body := rec.Body.String()
369 assert.Contains(t, body, "Pixel <9>", "names are escaped")
370 assert.NotContains(t, body, "Pixel <9>")
371 assert.Contains(t, body, "yubikey")
372 assert.Contains(t, body, "3d ago")
373 assert.Contains(t, body, "never")
374 assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
375 assert.Contains(t, body, `data-passkey="register"`)
376 // The name field and the add button are one form, so Enter submits.
377 start := strings.Index(body, "<form class=\"passkey-add\"")
378 require.GreaterOrEqual(t, start, 0, "the add form is on the page")
379 form := body[start:]
380 end := strings.Index(form, "</form>")
381 require.GreaterOrEqual(t, end, 0)
382 form = form[:end]
383 assert.Contains(t, form, "data-passkey-form")
384 assert.Contains(t, form, `data-passkey-name`)
385 assert.Contains(t, form, `type="submit"`)
386 assert.Contains(t, form, `data-passkey="register"`)
387 assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
388 assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
389 assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
390 assert.Contains(t, body, "data-passkey-status")
391 assert.Contains(t, body, `aria-live="polite"`)
392}
393
394func TestPasskeysPageFull(t *testing.T) {
395 t.Parallel()
396 f := newPasskeyServer(t)
397 list := make([]domain.Passkey, domain.MaxPasskeys)
398 for i := range list {
399 list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: fixedNow}
400 }
401 f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
402 rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
403 require.Equal(t, http.StatusOK, rec.Code)
404 assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
405 assert.Contains(t, rec.Body.String(), "Remove one to add another")
406}
407
408// Anybody but the owner gets the 404 page, and the list is not read.
409func TestPasskeysPageIsTheOwners(t *testing.T) {
410 t.Parallel()
411 f := newPasskeyServer(t)
412 for _, cookie := range []string{"", "stale"} {
413 rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
414 assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
415 assert.NotContains(t, rec.Body.String(), "Passkeys")
416 assert.NotContains(t, rec.Body.String(), "data-passkey")
417 }
418}
419
420func TestPasskeyRemove(t *testing.T) {
421 t.Parallel()
422 f := newPasskeyServer(t)
423 id := []byte{0xfb, 0xff, 'a'}
424 target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
425 f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
426
427 rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
428 assert.Equal(t, http.StatusSeeOther, rec.Code)
429 assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
430 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
431}
432
433func TestPasskeyRemoveErrors(t *testing.T) {
434 t.Parallel()
435 f := newPasskeyServer(t)
436 // Ids that are not unpadded base64url never reach the use case (the mock
437 // fails the test on a call).
438 for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
439 rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
440 assert.Equal(t, http.StatusNotFound, rec.Code, id)
441 }
442
443 f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
444 rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
445 assert.Equal(t, http.StatusNotFound, rec.Code)
446
447 f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
448 rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
449 assert.Equal(t, http.StatusInternalServerError, rec.Code)
450 assert.NotContains(t, rec.Body.String(), "kv down")
451}
452
453func TestPasskeyLoginPage(t *testing.T) {
454 t.Parallel()
455 f := newPasskeyServer(t)
456 rec := f.do(t, req{method: "GET", target: "/-/login"})
457 require.Equal(t, http.StatusOK, rec.Code)
458 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
459 assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
460 body := rec.Body.String()
461 assert.Contains(t, body, "<title>sign in")
462 assert.Contains(t, body, "Sign in with a passkey")
463 assert.Contains(t, body, `data-passkey="login"`)
464 // One entry point for the script: the button is the submit button of a form.
465 start := strings.Index(body, "<form class=\"state-act\"")
466 require.GreaterOrEqual(t, start, 0, "the sign-in form is on the page")
467 form := body[start:]
468 end := strings.Index(form, "</form>")
469 require.GreaterOrEqual(t, end, 0)
470 form = form[:end]
471 assert.Contains(t, form, "data-passkey-form")
472 assert.Contains(t, form, `type="submit"`)
473 assert.Contains(t, form, `data-passkey="login"`)
474 assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
475 assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
476 assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
477 assert.Contains(t, body, "ermite web login")
478 assert.NotContains(t, body, "<script>", "no inline script")
479
480 // A signed-in visitor goes home.
481 rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
482 assert.Equal(t, http.StatusSeeOther, rec.Code)
483 assert.Equal(t, "/", rec.Header().Get("Location"))
484}
485
486// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
487func TestPasskeyHeaderLinks(t *testing.T) {
488 t.Parallel()
489 f := newPasskeyServer(t)
490 rec := f.do(t, req{method: "GET", target: "/-/login"})
491 assert.Contains(t, rec.Body.String(), `href="/-/login"`)
492 assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
493 assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
494
495 f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
496 rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
497 assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
498 assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
499 assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
500}
501
502// Without the passkey service there is no route, no link and no script
503// behavior to reach: the routes answer like any unknown path.
504func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
505 t.Parallel()
506 lim := &blockingLimiter{}
507 srv := newTestServer(t, Config{
508 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
509 PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
510 })
511 do := func(method, target, cookie string) *httptest.ResponseRecorder {
512 r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
513 r.Header.Set("Origin", forgeOrigin)
514 r.Header.Set("Content-Type", "application/json")
515 if cookie != "" {
516 r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
517 }
518 rec := httptest.NewRecorder()
519 srv.ServeHTTP(rec, r)
520 return rec
521 }
522 for _, target := range []string{"/-/login", "/-/passkeys"} {
523 assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
524 assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
525 }
526 // A POST to a path without a route gets what any unknown path gets for a
527 // POST (the catch-all page route is GET only).
528 unknown := do("POST", "/-/nothing-here", "tok").Code
529 assert.Equal(t, http.StatusMethodNotAllowed, unknown)
530 for _, target := range []string{beginLogin, finishLogin} {
531 assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
532 assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
533 }
534 for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
535 assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
536 }
537 // And no link in the header.
538 rec := do("GET", "/", "")
539 assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
540 rec = do("GET", "/", "tok")
541 assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
542}
543
544// Passkeys need sessions: with the service alone nothing is served.
545func TestPasskeyRoutesNeedSessions(t *testing.T) {
546 t.Parallel()
547 srv := newTestServer(t, Config{
548 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
549 PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
550 })
551 rec := httptest.NewRecorder()
552 srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
553 assert.Equal(t, http.StatusNotFound, rec.Code)
554 r := httptest.NewRequest("POST", beginLogin, nil)
555 r.Header.Set("Origin", forgeOrigin)
556 rec = httptest.NewRecorder()
557 srv.ServeHTTP(rec, r)
558 assert.Equal(t, http.StatusForbidden, rec.Code)
559}
560
561// The existing login links keep working next to the passkey routes.
562func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
563 t.Parallel()
564 f := newPasskeyServer(t)
565 rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
566 assert.Equal(t, http.StatusOK, rec.Code)
567 assert.Contains(t, rec.Body.String(), "sign in as the owner")
568 rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
569 assert.Equal(t, http.StatusSeeOther, rec.Code)
570}
571
572func TestIsPasskeyLoginPath(t *testing.T) {
573 t.Parallel()
574 assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
575 assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
576 for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
577 assert.False(t, isPasskeyLoginPath(p), p)
578 }
579}
580
581// A finish that cannot be read is a 4xx, and is not counted as a failed
582// sign-in: the client is buggy, it did not guess at a passkey.
583func TestMalformedFinishIsNotAFailedSignIn(t *testing.T) {
584 t.Parallel()
585 f := newPasskeyServer(t)
586 for _, r := range []req{
587 {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
588 postJSON(finishLogin, "", `not json`),
589 postJSON(finishLogin, "", `{}`),
590 postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
591 } {
592 rec := f.do(t, r)
593 assert.GreaterOrEqual(t, rec.Code, 400)
594 assert.Less(t, rec.Code, 500)
595 }
596 assert.Zero(t, f.lim.fails)
597}
598
599// The client key the web hands to the use case is the one the failure limiter
600// counts under: the address with trusted proxies applied, an IPv4 address as it
601// is (mapped or not), an IPv6 one cut to its /64.
602func TestPasskeyLoginBeginPassesTheClientKey(t *testing.T) {
603 t.Parallel()
604 for name, tc := range map[string]struct{ remote, xff, want string }{
605 "ipv4": {"192.0.2.77:51000", "", "192.0.2.77"},
606 "ipv4 mapped": {"[::ffff:192.0.2.77]:51000", "", "192.0.2.77"},
607 "ipv6 host": {"[2001:db8:1:2::1]:51000", "", "2001:db8:1:2::/64"},
608 "ipv6 same /64": {"[2001:db8:1:2:ffff:ffff:ffff:ffff]:51000", "", "2001:db8:1:2::/64"},
609 "ipv6 another /64": {"[2001:db8:1:3::1]:51000", "", "2001:db8:1:3::/64"},
610 "forwarded for ignored": {"192.0.2.77:51000", "203.0.113.5", "192.0.2.77"},
611 } {
612 t.Run(name, func(t *testing.T) {
613 t.Parallel()
614 f := newPasskeyServer(t)
615 f.passkeys.EXPECT().BeginLogin(mock.Anything, tc.want).Return("c", []byte(`{}`), nil).Once()
616 r := httptest.NewRequest("POST", beginLogin, nil)
617 r.RemoteAddr = tc.remote
618 r.Header.Set("Origin", forgeOrigin)
619 if tc.xff != "" {
620 r.Header.Set("X-Forwarded-For", tc.xff) // no trusted proxy: ignored
621 }
622 rec := httptest.NewRecorder()
623 f.srv.ServeHTTP(rec, r)
624 assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
625 })
626 }
627}
628
629// The name travels with register/begin and is checked there.
630func TestPasskeyRegisterBeginChecksTheName(t *testing.T) {
631 t.Parallel()
632 f := newPasskeyServer(t)
633 f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once()
634 rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`))
635 assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
636
637 f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name").
638 Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once()
639 rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`))
640 assert.Equal(t, http.StatusBadRequest, rec.Code)
641 assert.Contains(t, rec.Body.String(), "control character")
642
643 // No body at all is no name; a body that is not JSON, or not the right type, is refused.
644 f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once()
645 assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code)
646 assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code)
647 assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code)
648}