v0.19.0 55a688e

passkeys_test.go RawHistoryFolder

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 &lt;9&gt;", "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.
487// They are icons with an accessible name.
488func TestPasskeyHeaderLinks(t *testing.T) {
489	t.Parallel()
490	f := newPasskeyServer(t)
491	rec := f.do(t, req{method: "GET", target: "/-/login"})
492	assert.Contains(t, rec.Body.String(), `href="/-/login" aria-label="Sign in"`)
493	assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
494	assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
495
496	f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
497	rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
498	assert.Contains(t, rec.Body.String(), `href="/-/passkeys" aria-label="Passkeys"`)
499	assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
500	assert.Contains(t, rec.Body.String(), `aria-label="Sign out"`)
501	assert.NotContains(t, rec.Body.String(), `aria-label="Sign in"`)
502}
503
504// Without the passkey service there is no route, no link and no script
505// behavior to reach: the routes answer like any unknown path.
506func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
507	t.Parallel()
508	lim := &blockingLimiter{}
509	srv := newTestServer(t, Config{
510		Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
511		PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
512	})
513	do := func(method, target, cookie string) *httptest.ResponseRecorder {
514		r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
515		r.Header.Set("Origin", forgeOrigin)
516		r.Header.Set("Content-Type", "application/json")
517		if cookie != "" {
518			r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
519		}
520		rec := httptest.NewRecorder()
521		srv.ServeHTTP(rec, r)
522		return rec
523	}
524	for _, target := range []string{"/-/login", "/-/passkeys"} {
525		assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
526		assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
527	}
528	// A POST to a path without a route gets what any unknown path gets for a
529	// POST (the catch-all page route is GET only).
530	unknown := do("POST", "/-/nothing-here", "tok").Code
531	assert.Equal(t, http.StatusMethodNotAllowed, unknown)
532	for _, target := range []string{beginLogin, finishLogin} {
533		assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
534		assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
535	}
536	for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
537		assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
538	}
539	// And no link in the header.
540	rec := do("GET", "/", "")
541	assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
542	rec = do("GET", "/", "tok")
543	assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
544}
545
546// Passkeys need sessions: with the service alone nothing is served.
547func TestPasskeyRoutesNeedSessions(t *testing.T) {
548	t.Parallel()
549	srv := newTestServer(t, Config{
550		Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
551		PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
552	})
553	rec := httptest.NewRecorder()
554	srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
555	assert.Equal(t, http.StatusNotFound, rec.Code)
556	r := httptest.NewRequest("POST", beginLogin, nil)
557	r.Header.Set("Origin", forgeOrigin)
558	rec = httptest.NewRecorder()
559	srv.ServeHTTP(rec, r)
560	assert.Equal(t, http.StatusForbidden, rec.Code)
561}
562
563// The existing login links keep working next to the passkey routes.
564func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
565	t.Parallel()
566	f := newPasskeyServer(t)
567	rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
568	assert.Equal(t, http.StatusOK, rec.Code)
569	assert.Contains(t, rec.Body.String(), "signing in as the owner")
570	rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
571	assert.Equal(t, http.StatusSeeOther, rec.Code)
572}
573
574func TestIsPasskeyLoginPath(t *testing.T) {
575	t.Parallel()
576	assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
577	assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
578	for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
579		assert.False(t, isPasskeyLoginPath(p), p)
580	}
581}
582
583// A finish that cannot be read is a 4xx, and is not counted as a failed
584// sign-in: the client is buggy, it did not guess at a passkey.
585func TestMalformedFinishIsNotAFailedSignIn(t *testing.T) {
586	t.Parallel()
587	f := newPasskeyServer(t)
588	for _, r := range []req{
589		{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
590		postJSON(finishLogin, "", `not json`),
591		postJSON(finishLogin, "", `{}`),
592		postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
593	} {
594		rec := f.do(t, r)
595		assert.GreaterOrEqual(t, rec.Code, 400)
596		assert.Less(t, rec.Code, 500)
597	}
598	assert.Zero(t, f.lim.fails)
599}
600
601// The client key the web hands to the use case is the one the failure limiter
602// counts under: the address with trusted proxies applied, an IPv4 address as it
603// is (mapped or not), an IPv6 one cut to its /64.
604func TestPasskeyLoginBeginPassesTheClientKey(t *testing.T) {
605	t.Parallel()
606	for name, tc := range map[string]struct{ remote, xff, want string }{
607		"ipv4":                  {"192.0.2.77:51000", "", "192.0.2.77"},
608		"ipv4 mapped":           {"[::ffff:192.0.2.77]:51000", "", "192.0.2.77"},
609		"ipv6 host":             {"[2001:db8:1:2::1]:51000", "", "2001:db8:1:2::/64"},
610		"ipv6 same /64":         {"[2001:db8:1:2:ffff:ffff:ffff:ffff]:51000", "", "2001:db8:1:2::/64"},
611		"ipv6 another /64":      {"[2001:db8:1:3::1]:51000", "", "2001:db8:1:3::/64"},
612		"forwarded for ignored": {"192.0.2.77:51000", "203.0.113.5", "192.0.2.77"},
613	} {
614		t.Run(name, func(t *testing.T) {
615			t.Parallel()
616			f := newPasskeyServer(t)
617			f.passkeys.EXPECT().BeginLogin(mock.Anything, tc.want).Return("c", []byte(`{}`), nil).Once()
618			r := httptest.NewRequest("POST", beginLogin, nil)
619			r.RemoteAddr = tc.remote
620			r.Header.Set("Origin", forgeOrigin)
621			if tc.xff != "" {
622				r.Header.Set("X-Forwarded-For", tc.xff) // no trusted proxy: ignored
623			}
624			rec := httptest.NewRecorder()
625			f.srv.ServeHTTP(rec, r)
626			assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
627		})
628	}
629}
630
631// The name travels with register/begin and is checked there.
632func TestPasskeyRegisterBeginChecksTheName(t *testing.T) {
633	t.Parallel()
634	f := newPasskeyServer(t)
635	f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once()
636	rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`))
637	assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
638
639	f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name").
640		Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once()
641	rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`))
642	assert.Equal(t, http.StatusBadRequest, rec.Code)
643	assert.Contains(t, rec.Body.String(), "control character")
644
645	// No body at all is no name; a body that is not JSON, or not the right type, is refused.
646	f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once()
647	assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code)
648	assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code)
649	assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code)
650}

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close