// SPDX-License-Identifier: AGPL-3.0-or-later package web import ( "context" "encoding/base64" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" "bnema.dev/ermite/internal/ports/mocks" ) const forgeOrigin = "https://forge.example" // blockingLimiter is a FailureLimiter that can be told to block. type blockingLimiter struct { blocked bool fails int } func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) { if l.blocked { return true, 90 * time.Second } return false, 0 } func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ } type passkeyFixture struct { srv *Server sessions *mocks.WebSessionService passkeys *mocks.WebPasskeyService lim *blockingLimiter } func newPasskeyServer(t *testing.T) *passkeyFixture { t.Helper() f := &passkeyFixture{ sessions: newSessions(t, "tok"), passkeys: mocks.NewWebPasskeyService(t), lim: &blockingLimiter{}, } f.srv = newTestServer(t, Config{ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim, Now: func() time.Time { return fixedNow }, }) return f } // fixedNow is the clock of the passkey pages: "3d ago" does not depend on when // the test runs. var fixedNow = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) type req struct { method, target string origin string // "" sends none cookie string contentType string body string } func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder { t.Helper() hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body)) hr.Host = "forge.example" if r.origin != "" { hr.Header.Set("Origin", r.origin) } if r.cookie != "" { hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie}) } if r.contentType != "" { hr.Header.Set("Content-Type", r.contentType) } rec := httptest.NewRecorder() f.srv.ServeHTTP(rec, hr) return rec } func postJSON(target, cookie, body string) req { return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body} } const finishBody = `{"ceremony":"c1","credential":{"id":"x","type":"public-key"}}` var ( beginLogin = "/-/passkeys/login/begin" finishLogin = "/-/passkeys/login/finish" beginRegister = "/-/passkeys/register/begin" finishRegister = "/-/passkeys/register/finish" ) // Every new POST needs the Origin of the public URL. func TestPasskeyPostsNeedTheOrigin(t *testing.T) { t.Parallel() f := newPasskeyServer(t) // the mocks fail the test on any call removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove" for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} { for _, cookie := range []string{"", "tok"} { for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} { rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody}) assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) } } } } // An anonymous browser may post to the two login endpoints only. func TestPasskeyAnonymousPostsAreExact(t *testing.T) { t.Parallel() f := newPasskeyServer(t) id := base64.RawURLEncoding.EncodeToString([]byte("a")) for _, target := range []string{ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove", "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x", "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin", "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin", "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f", } { rec := f.do(t, postJSON(target, "", finishBody)) assert.Equal(t, http.StatusForbidden, rec.Code, target) } // A GET is never a login. rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin}) assert.NotEqual(t, http.StatusOK, rec.Code) } func TestPasskeyLoginBegin(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once() rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String()) assert.Empty(t, rec.Header().Values("Set-Cookie")) } func TestPasskeyLoginBeginFailure(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once() rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.NotContains(t, rec.Body.String(), "secret detail") assert.Contains(t, rec.Body.String(), "request id") } func TestPasskeyLoginBlocked(t *testing.T) { t.Parallel() f := newPasskeyServer(t) // no use case call is expected f.lim.blocked = true // Blocked answers 429 whatever the request holds: the limiter comes before // the content type and the body are looked at. for _, r := range []req{ {method: "POST", target: beginLogin, origin: forgeOrigin}, postJSON(finishLogin, "", finishBody), {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"}, postJSON(finishLogin, "", `not json`), postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)), } { rec := f.do(t, r) assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%s %q", r.target, r.contentType) assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String()) assert.Equal(t, "91", rec.Header().Get("Retry-After")) assert.Empty(t, rec.Header().Values("Set-Cookie")) } assert.Zero(t, f.lim.fails, "a blocked client is not counted again") } func TestPasskeyLoginFinishSetsTheSession(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool { return string(b) == `{"id":"x","type":"public-key"}` })).Return("tok", 12*time.Hour, nil).Once() rec := f.do(t, postJSON(finishLogin, "", finishBody)) require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) assert.Zero(t, f.lim.fails) cookies := rec.Result().Cookies() require.Len(t, cookies, 1) c := cookies[0] assert.Equal(t, CookieName, c.Name) assert.Equal(t, "tok", c.Value) assert.True(t, c.Secure) assert.True(t, c.HttpOnly) assert.Equal(t, http.SameSiteStrictMode, c.SameSite) assert.Equal(t, "/", c.Path) assert.Equal(t, 12*3600, c.MaxAge) assert.Empty(t, c.Domain) set := rec.Header().Get("Set-Cookie") for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} { assert.Contains(t, set, attr) } } // A stale cookie that withSession cleared is replaced, not doubled. func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() rec := f.do(t, postJSON(finishLogin, "stale", finishBody)) // A stale cookie is not a session: the login endpoint still lets it through. require.Equal(t, http.StatusNoContent, rec.Code) require.Len(t, rec.Result().Cookies(), 1) assert.Equal(t, "tok", rec.Result().Cookies()[0].Value) } func TestPasskeyLoginFinishRefusal(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice() for range 2 { rec := f.do(t, postJSON(finishLogin, "", finishBody)) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String()) assert.Empty(t, rec.Header().Values("Set-Cookie")) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) } assert.Equal(t, 2, f.lim.fails, "every refusal counts") } func TestPasskeyLoginFinishInternalError(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once() rec := f.do(t, postJSON(finishLogin, "", finishBody)) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.NotContains(t, rec.Body.String(), "kv down") assert.Empty(t, rec.Header().Values("Set-Cookie")) assert.Zero(t, f.lim.fails, "a server failure is not the client's") } func TestPasskeyFinishNeedsJSON(t *testing.T) { t.Parallel() f := newPasskeyServer(t) // no use case call is expected for _, target := range []string{finishLogin, finishRegister} { for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} { cookie := "" if target == finishRegister { cookie = "tok" } rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody}) assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct) } } assert.Zero(t, f.lim.fails) } func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody}) assert.Equal(t, http.StatusNoContent, rec.Code) } func TestPasskeyFinishBodyLimit(t *testing.T) { t.Parallel() f := newPasskeyServer(t) big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}` for _, target := range []string{finishLogin, finishRegister} { rec := f.do(t, postJSON(target, "tok", big)) assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target) } // Right at the limit is read (and then refused as malformed, not as too large). exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}` require.Len(t, exact, 64<<10) rec := f.do(t, postJSON(finishLogin, "", exact)) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestPasskeyFinishMalformed(t *testing.T) { t.Parallel() f := newPasskeyServer(t) for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} { for _, target := range []string{finishLogin, finishRegister} { rec := f.do(t, postJSON(target, "tok", body)) assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body) } } } // The registration endpoints are the owner's. func TestPasskeyRegistration(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once() rec := f.do(t, postJSON(beginRegister, "tok", `{}`)) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String()) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", mock.MatchedBy(func(b []byte) bool { return string(b) == `{"id":"x","type":"public-key"}` })).Return(nil).Once() rec = f.do(t, postJSON(finishRegister, "tok", finishBody)) assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session") } func TestPasskeyRegistrationErrors(t *testing.T) { t.Parallel() for name, tc := range map[string]struct { err error status int body string }{ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"}, "limit": {domain.ErrResourceExhausted, 429, ""}, "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"}, "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"}, "not owner": {domain.ErrForbidden, 403, ""}, "server error": {errors.New("kv down"), 500, ""}, } { t.Run(name, func(t *testing.T) { t.Parallel() f := newPasskeyServer(t) f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once() rec := f.do(t, postJSON(finishRegister, "tok", finishBody)) assert.Equal(t, tc.status, rec.Code) assert.Contains(t, rec.Body.String(), `"error"`) assert.Contains(t, rec.Body.String(), tc.body) assert.NotContains(t, rec.Body.String(), "kv down") assert.Zero(t, f.lim.fails, "the owner's errors are not login failures") }) } f := newPasskeyServer(t) f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once() rec := f.do(t, postJSON(beginRegister, "tok", `{}`)) assert.Equal(t, http.StatusTooManyRequests, rec.Code) } func TestPasskeysPage(t *testing.T) { t.Parallel() f := newPasskeyServer(t) now := fixedNow list := []domain.Passkey{ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true}, {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)}, } f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once() rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) body := rec.Body.String() assert.Contains(t, body, "Pixel <9>", "names are escaped") assert.NotContains(t, body, "Pixel <9>") assert.Contains(t, body, "yubikey") assert.Contains(t, body, "3d ago") assert.Contains(t, body, "never") assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) assert.Contains(t, body, `data-passkey="register"`) // The name field and the add button are one form, so Enter submits. start := strings.Index(body, "