v0.30.0 c899179

web_passkey_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"crypto/sha256"
8	"encoding/base64"
9	"encoding/hex"
10	"encoding/json"
11	"net/http"
12	"net/url"
13	"strings"
14	"testing"
15
16	"github.com/descope/virtualwebauthn"
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/domain"
21)
22
23const passkeyOrigin = "https://git.example.test"
24
25var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin}
26
27// passkeyHarness is a forge whose public URL allows passkeys, with a virtual
28// authenticator in front of its real web handler.
29type passkeyHarness struct {
30	*cliHarness
31	b    browser
32	auth virtualwebauthn.Authenticator
33	cred virtualwebauthn.Credential
34}
35
36func newPasskeyHarness(t *testing.T) *passkeyHarness {
37	t.Helper()
38	h := newHarness(t, harnessOpts{publicURL: passkeyOrigin})
39	c := &cliHarness{harness: h, url: h.srv.URL}
40	return &passkeyHarness{
41		cliHarness: c, b: browser{h},
42		auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}),
43		cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
44	}
45}
46
47// postJSON posts a JSON body as the browser's script does.
48func (p *passkeyHarness) postJSON(path, token string, body any) page {
49	p.t.Helper()
50	var buf bytes.Buffer
51	if body != nil {
52		require.NoError(p.t, json.NewEncoder(&buf).Encode(body))
53	}
54	req, err := http.NewRequest("POST", p.url+path, &buf)
55	require.NoError(p.t, err)
56	req.Header.Set("Origin", passkeyOrigin)
57	req.Header.Set("Content-Type", "application/json")
58	if token != "" {
59		req.Header.Set("Cookie", sessionCookie+"="+token)
60	}
61	rec, err := http.DefaultClient.Do(req)
62	require.NoError(p.t, err)
63	defer func() { _ = rec.Body.Close() }()
64	var out bytes.Buffer
65	_, _ = out.ReadFrom(rec.Body)
66	return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()}
67}
68
69// passkeyLoginPath turns the link the CLI printed into a path on the test
70// server (the harness here has an https public URL).
71func passkeyLoginPath(t *testing.T, link string) string {
72	t.Helper()
73	u, err := url.Parse(strings.TrimSpace(link))
74	require.NoError(t, err)
75	assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
76	return u.Path
77}
78
79type beginReply struct {
80	Ceremony string          `json:"ceremony"`
81	Options  json.RawMessage `json:"options"`
82}
83
84// register adds the authenticator's credential as the owner holding token.
85func (p *passkeyHarness) register(token, name string) page {
86	p.t.Helper()
87	begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name})
88	require.Equal(p.t, http.StatusOK, begin.status, begin.body)
89	var br beginReply
90	require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
91	opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options))
92	require.NoError(p.t, err)
93	resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts)
94	fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
95	if fin.status == http.StatusNoContent {
96		p.auth.AddCredential(p.cred)
97		p.auth.Options.UserHandle = p.userHandle()
98	}
99	return fin
100}
101
102// userHandle reads the owner's WebAuthn user handle, which a real
103// authenticator got from the creation options.
104func (p *passkeyHarness) userHandle() []byte {
105	h, err := p.forge.store.Passkeys().UserHandle(p.ctx)
106	require.NoError(p.t, err)
107	return h
108}
109
110// login signs in with the authenticator; it returns the answer of finish.
111func (p *passkeyHarness) login() page {
112	p.t.Helper()
113	begin := p.postJSON("/-/passkeys/login/begin", "", nil)
114	require.Equal(p.t, http.StatusOK, begin.status, begin.body)
115	var br beginReply
116	require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
117	opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
118	require.NoError(p.t, err)
119	resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
120	return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
121}
122
123// AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs
124// out, signs in with the passkey, sees it in the CLI, and removing it ends
125// every session.
126func TestPasskeyEndToEnd(t *testing.T) {
127	p := newPasskeyHarness(t)
128	p.seed("secret", domain.VisibilityPrivate, "notes.txt")
129
130	// The server announces passkeys, and the pages link to them.
131	ver := p.cli(t, "", "version", "--json").ok(t)
132	assert.Contains(t, ver.stdout, `"passkeys"`)
133	assert.Empty(t, ver.stderr, "no feature is missing")
134	login := p.b.get("/-/login", nil)
135	require.Equal(t, http.StatusOK, login.status)
136	assert.Contains(t, login.body, `data-passkey="login"`)
137
138	// Anonymous: the list is a 404, registration is refused.
139	assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
140	assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status)
141
142	// Sign in with the CLI link (the way in before any passkey), add a passkey.
143	link := p.cli(t, adminToken, "web", "login").ok(t)
144	pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
145	require.Equal(t, http.StatusSeeOther, pg.status, pg.body)
146	token := pg.cookie(sessionCookie).Value
147
148	fin := p.register(token, "Test phone")
149	require.Equal(t, http.StatusNoContent, fin.status, fin.body)
150
151	list := p.b.get("/-/passkeys", as(token))
152	require.Equal(t, http.StatusOK, list.status)
153	assert.Contains(t, list.body, "Test phone")
154	assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control"))
155
156	// The same credential cannot be added twice.
157	dup := p.register(token, "again")
158	assert.Equal(t, http.StatusConflict, dup.status, dup.body)
159
160	// AC2: an anonymous browser signs in with the passkey: same cookie.
161	res := p.login()
162	require.Equal(t, http.StatusNoContent, res.status, res.body)
163	var ck *http.Cookie
164	for _, c := range (&http.Response{Header: res.hdr}).Cookies() {
165		if c.Name == sessionCookie {
166			ck = c
167		}
168	}
169	require.NotNil(t, ck)
170	assert.True(t, ck.Secure)
171	assert.True(t, ck.HttpOnly)
172	assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
173	assert.Equal(t, 12*3600, ck.MaxAge)
174	home := p.b.get("/", as(ck.Value))
175	assert.Contains(t, home.body, "secret", "the passkey session is the owner's")
176
177	// AC3: the CLI lists it, with its use; the id removes it.
178	out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t)
179	var listed []struct {
180		ID         string  `json:"id"`
181		Name       string  `json:"name"`
182		LastUsedAt *string `json:"last_used_at"`
183		Synced     bool    `json:"synced"`
184	}
185	require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed))
186	require.Len(t, listed, 1)
187	assert.Equal(t, "Test phone", listed[0].Name)
188	assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
189	assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
190
191	rm := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID).ok(t)
192	assert.Contains(t, rm.stderr, "every browser is signed out")
193	for _, tok := range []string{token, ck.Value} {
194		assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
195	}
196	// The removed passkey no longer signs in, and is not there to remove again.
197	res = p.login()
198	assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
199	assert.Empty(t, res.hdr.Values("Set-Cookie"))
200	again := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID)
201	require.Error(t, again.err)
202	assert.Contains(t, again.err.Error(), "not found")
203}
204
205// A passkey that signs in is refused when the origin is wrong, and nothing is set.
206func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) {
207	p := newPasskeyHarness(t)
208	link := p.cli(t, adminToken, "web", "login").ok(t)
209	pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
210	token := pg.cookie(sessionCookie).Value
211	require.Equal(t, http.StatusNoContent, p.register(token, "k").status)
212
213	// The Origin header is wrong: the gate refuses before anything else.
214	req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader(""))
215	require.NoError(t, err)
216	req.Header.Set("Origin", "https://evil.example")
217	resp, err := http.DefaultClient.Do(req)
218	require.NoError(t, err)
219	_ = resp.Body.Close()
220	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
221
222	// The authenticator answers for another relying party.
223	begin := p.postJSON("/-/passkeys/login/begin", "", nil)
224	var br beginReply
225	require.NoError(t, json.Unmarshal([]byte(begin.body), &br))
226	opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
227	require.NoError(t, err)
228	evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"}
229	resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts)
230	fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)})
231	assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body)
232	assert.Empty(t, fin.hdr.Values("Set-Cookie"))
233
234	// The ceremony is burnt: even the right answer is refused afterwards.
235	good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
236	fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)})
237	assert.Equal(t, http.StatusUnauthorized, fin.status)
238}
239
240// AC4: on the harness's http public URL (not https), passkeys are off: no
241// route, no link, no feature, and the RPCs say so.
242func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) {
243	c := newCLIHarness(t) // http://example.test
244	b := browser{c.harness}
245	assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status)
246	assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route")
247	token := c.signIn(t)
248	assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status)
249	home := b.get("/", as(token))
250	assert.NotContains(t, home.body, "/-/passkeys")
251	assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`)
252
253	res := c.cli(t, adminToken, "web", "passkey", "list")
254	require.Error(t, res.err)
255	assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
256	ver := c.cli(t, "", "version", "--json").ok(t)
257	assert.NotContains(t, ver.stdout, `"features": [`+"\n"+`      "passkeys"`)
258}
259
260// A passkey whose counter does not advance (a hardware key that was cloned) is
261// refused end to end, with nothing set and the stored counter kept, while a
262// counter that advances signs in. Without the sign-count check of the use case
263// the clone would sign in: the WebAuthn adapter accepts it and only reports the
264// stored counter back.
265func TestPasskeyClonedAuthenticator(t *testing.T) {
266	p := newPasskeyHarness(t)
267	link := p.cli(t, adminToken, "web", "login").ok(t)
268	pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
269	token := pg.cookie(sessionCookie).Value
270
271	// A hardware key: its counter counts up from 1.
272	p.cred.Counter = 1
273	require.Equal(t, http.StatusNoContent, p.register(token, "key").status)
274
275	p.cred.Counter = 5
276	res := p.login()
277	require.Equal(t, http.StatusNoContent, res.status, res.body)
278
279	// The clone presents the counter it last saw: not higher than the stored 5.
280	for _, counter := range []uint32{5, 3} {
281		p.cred.Counter = counter
282		res = p.login()
283		assert.Equal(t, http.StatusUnauthorized, res.status, "counter %d: %s", counter, res.body)
284		assert.Empty(t, res.hdr.Values("Set-Cookie"), "no session for a clone")
285	}
286
287	// The refusals recorded nothing: the next higher counter still works.
288	p.cred.Counter = 6
289	res = p.login()
290	assert.Equal(t, http.StatusNoContent, res.status, res.body)
291
292	stored, err := p.forge.store.Passkeys().List(p.ctx)
293	require.NoError(t, err)
294	require.Len(t, stored, 1)
295	assert.Equal(t, uint32(6), stored[0].SignCount)
296
297	// The warning names the passkey by its short reference, never by its id.
298	sum := sha256.Sum256(p.cred.ID)
299	ref := hex.EncodeToString(sum[:])[:8]
300	var warns []string
301	for _, line := range strings.Split(p.logs.String(), "\n") {
302		var m map[string]any
303		if json.Unmarshal([]byte(line), &m) == nil && m["level"] == "warn" && m["passkey"] == ref {
304			warns = append(warns, line)
305		}
306	}
307	assert.Len(t, warns, 2, "one warning per refused clone")
308	assert.NotContains(t, p.logs.String(), base64.RawURLEncoding.EncodeToString(p.cred.ID))
309}

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