detached 21bc4dd

feat(app): wire passkeys when the public URL allows them

commit
21bc4dd7c4b229184c9ab212991d1d93838fc6bf
author
brice · · 2026-10-12 15:16 UTC
parent
26943f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +373 -3
M internal/app/e2e_helpers_test.go +5 -0
@@ -48,6 +48,8 @@ type harnessOpts struct {
4848 eventBuffer int
4949 // revalidateEvery overrides how often a watcher's token is re-checked.
5050 revalidateEvery time.Duration
51+ // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52+ publicURL string
5153 }
5254
5355 // newHarness starts a forge. The data directory is short: the hook socket
@@ -66,6 +68,9 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
6668 config.EnvGit: gitBin,
6769 config.EnvAddr: "127.0.0.1:1",
6870 }
71+ if o.publicURL != "" {
72+ env[config.EnvPublicURL] = o.publicURL
73+ }
6974 if o.trustedProxies != "" {
7075 env[config.EnvTrustedProxies] = o.trustedProxies
7176 }
@@ -48,6 +48,8 @@ type harnessOpts struct {
48 eventBuffer int48 eventBuffer int
49 // revalidateEvery overrides how often a watcher's token is re-checked.49 // revalidateEvery overrides how often a watcher's token is re-checked.
50 revalidateEvery time.Duration50 revalidateEvery time.Duration
51+ // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52+ publicURL string
51 }53 }
52 54
53 // newHarness starts a forge. The data directory is short: the hook socket55 // newHarness starts a forge. The data directory is short: the hook socket
@@ -66,6 +68,9 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
66 config.EnvGit: gitBin,68 config.EnvGit: gitBin,
67 config.EnvAddr: "127.0.0.1:1",69 config.EnvAddr: "127.0.0.1:1",
68 }70 }
71+ if o.publicURL != "" {
72+ env[config.EnvPublicURL] = o.publicURL
73+ }
69 if o.trustedProxies != "" {74 if o.trustedProxies != "" {
70 env[config.EnvTrustedProxies] = o.trustedProxies75 env[config.EnvTrustedProxies] = o.trustedProxies
71 }76 }
M internal/app/forge.go +26 -3
@@ -24,6 +24,7 @@ import (
2424 "bnema.dev/ermite/internal/adapters/rpc"
2525 "bnema.dev/ermite/internal/adapters/sshsig"
2626 "bnema.dev/ermite/internal/adapters/web"
27+ "bnema.dev/ermite/internal/adapters/webauthn"
2728 "bnema.dev/ermite/internal/clientlimit"
2829 "bnema.dev/ermite/internal/config"
2930 "bnema.dev/ermite/internal/domain"
@@ -73,9 +74,11 @@ type forge struct {
7374 MergeRequests *usecase.MergeRequests
7475 SigningKey *usecase.SigningKey
7576 Sessions *usecase.Sessions
76- EventLog *usecase.Events
77- API *rpc.API
78- Bus ports.EventBus
77+ // Passkeys is nil when passkeys are not available (passkeysAvailable).
78+ Passkeys *usecase.Passkeys
79+ EventLog *usecase.Events
80+ API *rpc.API
81+ Bus ports.EventBus
7982 }
8083
8184 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -208,9 +211,28 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
208211 return nil, err
209212 }
210213
214+ // Owner passkeys (ADR 0026): built only when the public URL allows them.
215+ // When it does, a verifier that cannot be built is fatal.
216+ if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
217+ verifier, err := webauthn.New(cfg.PublicURL)
218+ if err != nil {
219+ return nil, fmt.Errorf("passkeys: %w", err)
220+ }
221+ f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
222+ log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
223+ } else {
224+ log.Info().Str("reason", reason).Msg("passkeys disabled")
225+ }
226+
211227 // One budget of failed authentications per client, for Git, the API and
212228 // the web login.
213229 limiter := clientlimit.NewLimiter(nil)
230+ // A nil *usecase.Passkeys must not become a non-nil interface: the API
231+ // tests for nil to answer failed_precondition and to leave the feature out.
232+ var apiPasskeys ports.PasskeyService
233+ if f.Passkeys != nil {
234+ apiPasskeys = f.Passkeys
235+ }
214236 f.API = rpc.New(rpc.Config{
215237 Auth: f.Auth,
216238 Repos: f.Repos,
@@ -220,6 +242,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
220242 MergeRequests: f.MergeRequests,
221243 SigningKey: f.SigningKey,
222244 Sessions: f.Sessions,
245+ Passkeys: apiPasskeys,
223246 Version: serverVersion(),
224247 TrustedProxies: cfg.TrustedProxies,
225248 Limiter: limiter,
@@ -24,6 +24,7 @@ import (
24 "bnema.dev/ermite/internal/adapters/rpc"24 "bnema.dev/ermite/internal/adapters/rpc"
25 "bnema.dev/ermite/internal/adapters/sshsig"25 "bnema.dev/ermite/internal/adapters/sshsig"
26 "bnema.dev/ermite/internal/adapters/web"26 "bnema.dev/ermite/internal/adapters/web"
27+ "bnema.dev/ermite/internal/adapters/webauthn"
27 "bnema.dev/ermite/internal/clientlimit"28 "bnema.dev/ermite/internal/clientlimit"
28 "bnema.dev/ermite/internal/config"29 "bnema.dev/ermite/internal/config"
29 "bnema.dev/ermite/internal/domain"30 "bnema.dev/ermite/internal/domain"
@@ -73,9 +74,11 @@ type forge struct {
73 MergeRequests *usecase.MergeRequests74 MergeRequests *usecase.MergeRequests
74 SigningKey *usecase.SigningKey75 SigningKey *usecase.SigningKey
75 Sessions *usecase.Sessions76 Sessions *usecase.Sessions
76- EventLog *usecase.Events77+ // Passkeys is nil when passkeys are not available (passkeysAvailable).
77- API *rpc.API78+ Passkeys *usecase.Passkeys
78- Bus ports.EventBus79+ EventLog *usecase.Events
80+ API *rpc.API
81+ Bus ports.EventBus
79 }82 }
80 83
81 // newSigningSeed makes the seed of a new ed25519 signing key.84 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -208,9 +211,28 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
208 return nil, err211 return nil, err
209 }212 }
210 213
214+ // Owner passkeys (ADR 0026): built only when the public URL allows them.
215+ // When it does, a verifier that cannot be built is fatal.
216+ if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
217+ verifier, err := webauthn.New(cfg.PublicURL)
218+ if err != nil {
219+ return nil, fmt.Errorf("passkeys: %w", err)
220+ }
221+ f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
222+ log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
223+ } else {
224+ log.Info().Str("reason", reason).Msg("passkeys disabled")
225+ }
226+
211 // One budget of failed authentications per client, for Git, the API and227 // One budget of failed authentications per client, for Git, the API and
212 // the web login.228 // the web login.
213 limiter := clientlimit.NewLimiter(nil)229 limiter := clientlimit.NewLimiter(nil)
230+ // A nil *usecase.Passkeys must not become a non-nil interface: the API
231+ // tests for nil to answer failed_precondition and to leave the feature out.
232+ var apiPasskeys ports.PasskeyService
233+ if f.Passkeys != nil {
234+ apiPasskeys = f.Passkeys
235+ }
214 f.API = rpc.New(rpc.Config{236 f.API = rpc.New(rpc.Config{
215 Auth: f.Auth,237 Auth: f.Auth,
216 Repos: f.Repos,238 Repos: f.Repos,
@@ -220,6 +242,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
220 MergeRequests: f.MergeRequests,242 MergeRequests: f.MergeRequests,
221 SigningKey: f.SigningKey,243 SigningKey: f.SigningKey,
222 Sessions: f.Sessions,244 Sessions: f.Sessions,
245+ Passkeys: apiPasskeys,
223 Version: serverVersion(),246 Version: serverVersion(),
224 TrustedProxies: cfg.TrustedProxies,247 TrustedProxies: cfg.TrustedProxies,
225 Limiter: limiter,248 Limiter: limiter,
M internal/app/web.go +59 -0
@@ -29,6 +29,12 @@ import (
2929 // checks it with sessions); every other request is anonymous. The web passes
3030 // the principal of the request to each use case call itself.
3131 func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32+ // A nil *usecase.Passkeys must not become a non-nil interface (the web
33+ // tests for nil to leave the passkey routes out).
34+ var passkeys ports.WebPasskeyService
35+ if f.Passkeys != nil {
36+ passkeys = webPasskeys{f.Passkeys}
37+ }
3238 return web.New(web.Config{
3339 Repos: f.Repos,
3440 Browse: f.Browse,
@@ -39,6 +45,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3945 Version: serverVersion(),
4046
4147 Sessions: webSessions{f.Sessions},
48+ Passkeys: passkeys,
4249 Limiter: limiter,
4350 TrustedProxies: cfg.TrustedProxies,
4451 })
@@ -61,6 +68,25 @@ func sessionsNeedHTTPS(publicURL string) bool {
6168 return err != nil || !ip.Unmap().IsLoopback()
6269 }
6370
71+// passkeysAvailable reports whether passkeys can work on this public URL, and
72+// if not, why. WebAuthn needs a secure context (https, or localhost) and a
73+// relying party ID that is a DNS name, not an IP address, so a server on
74+// http://127.0.0.1 starts without passkeys. A sign-in needs the session
75+// cookie, hence the same https rule as sessionsNeedHTTPS.
76+func passkeysAvailable(publicURL string) (ok bool, reason string) {
77+ if sessionsNeedHTTPS(publicURL) {
78+ return false, "needs https"
79+ }
80+ u, err := url.Parse(publicURL)
81+ if err != nil || u.Hostname() == "" {
82+ return false, "public URL has no host"
83+ }
84+ if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil {
85+ return false, "public URL host is an IP address"
86+ }
87+ return true, ""
88+}
89+
6490 // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
6591 // not work on an http public URL. It does not stop the server: the pages,
6692 // the API and Git do not need the cookie.
@@ -131,6 +157,39 @@ func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal
131157 return p, true, nil
132158 }
133159
160+// webPasskeys adapts the passkey use cases to ports.WebPasskeyService: the
161+// session a passkey sign-in opens is given to the web as a token and a
162+// lifetime, like webSessions.Login does for a link, and a refusal is
163+// ports.ErrLinkRejected.
164+type webPasskeys struct{ *usecase.Passkeys }
165+
166+var _ ports.WebPasskeyService = webPasskeys{}
167+
168+func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
169+ token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response)
170+ if errors.Is(err, domain.ErrUnauthenticated) {
171+ return "", 0, ports.ErrLinkRejected
172+ }
173+ if err != nil {
174+ return "", 0, err
175+ }
176+ // Same lifetime as webSessions.Login: the session token carries its own
177+ // expiry, the cookie lasts as long.
178+ lifetime := usecase.SessionTTL
179+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
180+ lifetime = d.Round(time.Second)
181+ }
182+ if lifetime < time.Second {
183+ return "", 0, ports.ErrLinkRejected
184+ }
185+ return token, lifetime, nil
186+}
187+
188+func (p webPasskeys) FinishRegistration(ctx context.Context, owner domain.Principal, ceremony, name string, response []byte) error {
189+ _, err := p.Passkeys.FinishRegistration(ctx, owner, ceremony, name, response)
190+ return err
191+}
192+
134193 // webRoutes mounts the web on the patterns it serves.
135194 func webRoutes(h http.Handler) []httpserver.Route {
136195 routes := make([]httpserver.Route, 0, len(web.Patterns))
@@ -29,6 +29,12 @@ import (
29 // checks it with sessions); every other request is anonymous. The web passes29 // checks it with sessions); every other request is anonymous. The web passes
30 // the principal of the request to each use case call itself.30 // the principal of the request to each use case call itself.
31 func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {31 func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32+ // A nil *usecase.Passkeys must not become a non-nil interface (the web
33+ // tests for nil to leave the passkey routes out).
34+ var passkeys ports.WebPasskeyService
35+ if f.Passkeys != nil {
36+ passkeys = webPasskeys{f.Passkeys}
37+ }
32 return web.New(web.Config{38 return web.New(web.Config{
33 Repos: f.Repos,39 Repos: f.Repos,
34 Browse: f.Browse,40 Browse: f.Browse,
@@ -39,6 +45,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
39 Version: serverVersion(),45 Version: serverVersion(),
40 46
41 Sessions: webSessions{f.Sessions},47 Sessions: webSessions{f.Sessions},
48+ Passkeys: passkeys,
42 Limiter: limiter,49 Limiter: limiter,
43 TrustedProxies: cfg.TrustedProxies,50 TrustedProxies: cfg.TrustedProxies,
44 })51 })
@@ -61,6 +68,25 @@ func sessionsNeedHTTPS(publicURL string) bool {
61 return err != nil || !ip.Unmap().IsLoopback()68 return err != nil || !ip.Unmap().IsLoopback()
62 }69 }
63 70
71+// passkeysAvailable reports whether passkeys can work on this public URL, and
72+// if not, why. WebAuthn needs a secure context (https, or localhost) and a
73+// relying party ID that is a DNS name, not an IP address, so a server on
74+// http://127.0.0.1 starts without passkeys. A sign-in needs the session
75+// cookie, hence the same https rule as sessionsNeedHTTPS.
76+func passkeysAvailable(publicURL string) (ok bool, reason string) {
77+ if sessionsNeedHTTPS(publicURL) {
78+ return false, "needs https"
79+ }
80+ u, err := url.Parse(publicURL)
81+ if err != nil || u.Hostname() == "" {
82+ return false, "public URL has no host"
83+ }
84+ if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil {
85+ return false, "public URL host is an IP address"
86+ }
87+ return true, ""
88+}
89+
64 // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will90 // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
65 // not work on an http public URL. It does not stop the server: the pages,91 // not work on an http public URL. It does not stop the server: the pages,
66 // the API and Git do not need the cookie.92 // the API and Git do not need the cookie.
@@ -131,6 +157,39 @@ func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal
131 return p, true, nil157 return p, true, nil
132 }158 }
133 159
160+// webPasskeys adapts the passkey use cases to ports.WebPasskeyService: the
161+// session a passkey sign-in opens is given to the web as a token and a
162+// lifetime, like webSessions.Login does for a link, and a refusal is
163+// ports.ErrLinkRejected.
164+type webPasskeys struct{ *usecase.Passkeys }
165+
166+var _ ports.WebPasskeyService = webPasskeys{}
167+
168+func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
169+ token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response)
170+ if errors.Is(err, domain.ErrUnauthenticated) {
171+ return "", 0, ports.ErrLinkRejected
172+ }
173+ if err != nil {
174+ return "", 0, err
175+ }
176+ // Same lifetime as webSessions.Login: the session token carries its own
177+ // expiry, the cookie lasts as long.
178+ lifetime := usecase.SessionTTL
179+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
180+ lifetime = d.Round(time.Second)
181+ }
182+ if lifetime < time.Second {
183+ return "", 0, ports.ErrLinkRejected
184+ }
185+ return token, lifetime, nil
186+}
187+
188+func (p webPasskeys) FinishRegistration(ctx context.Context, owner domain.Principal, ceremony, name string, response []byte) error {
189+ _, err := p.Passkeys.FinishRegistration(ctx, owner, ceremony, name, response)
190+ return err
191+}
192+
134 // webRoutes mounts the web on the patterns it serves.193 // webRoutes mounts the web on the patterns it serves.
135 func webRoutes(h http.Handler) []httpserver.Route {194 func webRoutes(h http.Handler) []httpserver.Route {
136 routes := make([]httpserver.Route, 0, len(web.Patterns))195 routes := make([]httpserver.Route, 0, len(web.Patterns))
A internal/app/web_passkey_test.go +256 -0
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "encoding/base64"
8+ "encoding/json"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "testing"
13+
14+ "github.com/descope/virtualwebauthn"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+const passkeyOrigin = "https://git.example.test"
22+
23+var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin}
24+
25+// passkeyHarness is a forge whose public URL allows passkeys, with a virtual
26+// authenticator in front of its real web handler.
27+type passkeyHarness struct {
28+ *cliHarness
29+ b browser
30+ auth virtualwebauthn.Authenticator
31+ cred virtualwebauthn.Credential
32+}
33+
34+func newPasskeyHarness(t *testing.T) *passkeyHarness {
35+ t.Helper()
36+ h := newHarness(t, harnessOpts{publicURL: passkeyOrigin})
37+ c := &cliHarness{harness: h, url: h.srv.URL}
38+ return &passkeyHarness{
39+ cliHarness: c, b: browser{h},
40+ auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}),
41+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
42+ }
43+}
44+
45+// postJSON posts a JSON body as the browser's script does.
46+func (p *passkeyHarness) postJSON(path, token string, body any) page {
47+ p.t.Helper()
48+ var buf bytes.Buffer
49+ if body != nil {
50+ require.NoError(p.t, json.NewEncoder(&buf).Encode(body))
51+ }
52+ req, err := http.NewRequest("POST", p.url+path, &buf)
53+ require.NoError(p.t, err)
54+ req.Header.Set("Origin", passkeyOrigin)
55+ req.Header.Set("Content-Type", "application/json")
56+ if token != "" {
57+ req.Header.Set("Cookie", sessionCookie+"="+token)
58+ }
59+ rec, err := http.DefaultClient.Do(req)
60+ require.NoError(p.t, err)
61+ defer func() { _ = rec.Body.Close() }()
62+ var out bytes.Buffer
63+ _, _ = out.ReadFrom(rec.Body)
64+ return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()}
65+}
66+
67+// passkeyLoginPath turns the link the CLI printed into a path on the test
68+// server (the harness here has an https public URL).
69+func passkeyLoginPath(t *testing.T, link string) string {
70+ t.Helper()
71+ u, err := url.Parse(strings.TrimSpace(link))
72+ require.NoError(t, err)
73+ assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
74+ return u.Path
75+}
76+
77+type beginReply struct {
78+ Ceremony string `json:"ceremony"`
79+ Options json.RawMessage `json:"options"`
80+}
81+
82+// register adds the authenticator's credential as the owner holding token.
83+func (p *passkeyHarness) register(token, name string) page {
84+ p.t.Helper()
85+ begin := p.postJSON("/-/passkeys/register/begin", token, nil)
86+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
87+ var br beginReply
88+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
89+ opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options))
90+ require.NoError(p.t, err)
91+ resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts)
92+ fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "name": name, "credential": json.RawMessage(resp)})
93+ if fin.status == http.StatusNoContent {
94+ p.auth.AddCredential(p.cred)
95+ p.auth.Options.UserHandle = p.userHandle()
96+ }
97+ return fin
98+}
99+
100+// userHandle reads the owner's WebAuthn user handle, which a real
101+// authenticator got from the creation options.
102+func (p *passkeyHarness) userHandle() []byte {
103+ h, err := p.forge.store.Passkeys().UserHandle(p.ctx)
104+ require.NoError(p.t, err)
105+ return h
106+}
107+
108+// login signs in with the authenticator; it returns the answer of finish.
109+func (p *passkeyHarness) login() page {
110+ p.t.Helper()
111+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
112+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
113+ var br beginReply
114+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
115+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
116+ require.NoError(p.t, err)
117+ resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
118+ return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
119+}
120+
121+// AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs
122+// out, signs in with the passkey, sees it in the CLI, and removing it ends
123+// every session.
124+func TestPasskeyEndToEnd(t *testing.T) {
125+ p := newPasskeyHarness(t)
126+ p.seed("secret", domain.VisibilityPrivate, "notes.txt")
127+
128+ // The server announces passkeys, and the pages link to them.
129+ ver := p.cli(t, "", "version", "--json").ok(t)
130+ assert.Contains(t, ver.stdout, `"passkeys"`)
131+ assert.Empty(t, ver.stderr, "no feature is missing")
132+ login := p.b.get("/-/login", nil)
133+ require.Equal(t, http.StatusOK, login.status)
134+ assert.Contains(t, login.body, `data-passkey="login"`)
135+
136+ // Anonymous: the list is a 404, registration is refused.
137+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
138+ assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", nil).status)
139+
140+ // Sign in with the CLI link (the way in before any passkey), add a passkey.
141+ link := p.cli(t, adminToken, "web", "login").ok(t)
142+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
143+ require.Equal(t, http.StatusSeeOther, pg.status, pg.body)
144+ token := pg.cookie(sessionCookie).Value
145+
146+ fin := p.register(token, "Test phone")
147+ require.Equal(t, http.StatusNoContent, fin.status, fin.body)
148+
149+ list := p.b.get("/-/passkeys", as(token))
150+ require.Equal(t, http.StatusOK, list.status)
151+ assert.Contains(t, list.body, "Test phone")
152+ assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control"))
153+
154+ // The same credential cannot be added twice.
155+ dup := p.register(token, "again")
156+ assert.Equal(t, http.StatusConflict, dup.status, dup.body)
157+
158+ // AC2: an anonymous browser signs in with the passkey: same cookie.
159+ res := p.login()
160+ require.Equal(t, http.StatusNoContent, res.status, res.body)
161+ var ck *http.Cookie
162+ for _, c := range (&http.Response{Header: res.hdr}).Cookies() {
163+ if c.Name == sessionCookie {
164+ ck = c
165+ }
166+ }
167+ require.NotNil(t, ck)
168+ assert.True(t, ck.Secure)
169+ assert.True(t, ck.HttpOnly)
170+ assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
171+ assert.Equal(t, 12*3600, ck.MaxAge)
172+ home := p.b.get("/", as(ck.Value))
173+ assert.Contains(t, home.body, "secret", "the passkey session is the owner's")
174+
175+ // AC3: the CLI lists it, with its use; the id removes it.
176+ out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t)
177+ var listed []struct {
178+ ID string `json:"id"`
179+ Name string `json:"name"`
180+ LastUsedAt *string `json:"last_used_at"`
181+ Synced bool `json:"synced"`
182+ }
183+ require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed))
184+ require.Len(t, listed, 1)
185+ assert.Equal(t, "Test phone", listed[0].Name)
186+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
187+ assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
188+
189+ rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t)
190+ assert.Contains(t, rm.stderr, "every browser is signed out")
191+ for _, tok := range []string{token, ck.Value} {
192+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
193+ }
194+ // The removed passkey no longer signs in, and is not there to remove again.
195+ res = p.login()
196+ assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
197+ assert.Empty(t, res.hdr.Values("Set-Cookie"))
198+ again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID)
199+ require.Error(t, again.err)
200+ assert.Contains(t, again.err.Error(), "not found")
201+}
202+
203+// A passkey that signs in is refused when the origin is wrong, and nothing is set.
204+func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) {
205+ p := newPasskeyHarness(t)
206+ link := p.cli(t, adminToken, "web", "login").ok(t)
207+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
208+ token := pg.cookie(sessionCookie).Value
209+ require.Equal(t, http.StatusNoContent, p.register(token, "k").status)
210+
211+ // The Origin header is wrong: the gate refuses before anything else.
212+ req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader(""))
213+ require.NoError(t, err)
214+ req.Header.Set("Origin", "https://evil.example")
215+ resp, err := http.DefaultClient.Do(req)
216+ require.NoError(t, err)
217+ _ = resp.Body.Close()
218+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
219+
220+ // The authenticator answers for another relying party.
221+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
222+ var br beginReply
223+ require.NoError(t, json.Unmarshal([]byte(begin.body), &br))
224+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
225+ require.NoError(t, err)
226+ evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"}
227+ resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts)
228+ fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)})
229+ assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body)
230+ assert.Empty(t, fin.hdr.Values("Set-Cookie"))
231+
232+ // The ceremony is burnt: even the right answer is refused afterwards.
233+ good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
234+ fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)})
235+ assert.Equal(t, http.StatusUnauthorized, fin.status)
236+}
237+
238+// AC4: on the harness's http public URL (not https), passkeys are off: no
239+// route, no link, no feature, and the RPCs say so.
240+func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) {
241+ c := newCLIHarness(t) // http://example.test
242+ b := browser{c.harness}
243+ assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status)
244+ assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route")
245+ token := c.signIn(t)
246+ assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status)
247+ home := b.get("/", as(token))
248+ assert.NotContains(t, home.body, "/-/passkeys")
249+ assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`)
250+
251+ res := c.cli(t, adminToken, "web", "passkey", "list")
252+ require.Error(t, res.err)
253+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
254+ ver := c.cli(t, "", "version", "--json").ok(t)
255+ assert.NotContains(t, ver.stdout, `"features": [`+"\n"+` "passkeys"`)
256+}
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "encoding/base64"
8+ "encoding/json"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "testing"
13+
14+ "github.com/descope/virtualwebauthn"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+const passkeyOrigin = "https://git.example.test"
22+
23+var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin}
24+
25+// passkeyHarness is a forge whose public URL allows passkeys, with a virtual
26+// authenticator in front of its real web handler.
27+type passkeyHarness struct {
28+ *cliHarness
29+ b browser
30+ auth virtualwebauthn.Authenticator
31+ cred virtualwebauthn.Credential
32+}
33+
34+func newPasskeyHarness(t *testing.T) *passkeyHarness {
35+ t.Helper()
36+ h := newHarness(t, harnessOpts{publicURL: passkeyOrigin})
37+ c := &cliHarness{harness: h, url: h.srv.URL}
38+ return &passkeyHarness{
39+ cliHarness: c, b: browser{h},
40+ auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}),
41+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
42+ }
43+}
44+
45+// postJSON posts a JSON body as the browser's script does.
46+func (p *passkeyHarness) postJSON(path, token string, body any) page {
47+ p.t.Helper()
48+ var buf bytes.Buffer
49+ if body != nil {
50+ require.NoError(p.t, json.NewEncoder(&buf).Encode(body))
51+ }
52+ req, err := http.NewRequest("POST", p.url+path, &buf)
53+ require.NoError(p.t, err)
54+ req.Header.Set("Origin", passkeyOrigin)
55+ req.Header.Set("Content-Type", "application/json")
56+ if token != "" {
57+ req.Header.Set("Cookie", sessionCookie+"="+token)
58+ }
59+ rec, err := http.DefaultClient.Do(req)
60+ require.NoError(p.t, err)
61+ defer func() { _ = rec.Body.Close() }()
62+ var out bytes.Buffer
63+ _, _ = out.ReadFrom(rec.Body)
64+ return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()}
65+}
66+
67+// passkeyLoginPath turns the link the CLI printed into a path on the test
68+// server (the harness here has an https public URL).
69+func passkeyLoginPath(t *testing.T, link string) string {
70+ t.Helper()
71+ u, err := url.Parse(strings.TrimSpace(link))
72+ require.NoError(t, err)
73+ assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
74+ return u.Path
75+}
76+
77+type beginReply struct {
78+ Ceremony string `json:"ceremony"`
79+ Options json.RawMessage `json:"options"`
80+}
81+
82+// register adds the authenticator's credential as the owner holding token.
83+func (p *passkeyHarness) register(token, name string) page {
84+ p.t.Helper()
85+ begin := p.postJSON("/-/passkeys/register/begin", token, nil)
86+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
87+ var br beginReply
88+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
89+ opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options))
90+ require.NoError(p.t, err)
91+ resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts)
92+ fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "name": name, "credential": json.RawMessage(resp)})
93+ if fin.status == http.StatusNoContent {
94+ p.auth.AddCredential(p.cred)
95+ p.auth.Options.UserHandle = p.userHandle()
96+ }
97+ return fin
98+}
99+
100+// userHandle reads the owner's WebAuthn user handle, which a real
101+// authenticator got from the creation options.
102+func (p *passkeyHarness) userHandle() []byte {
103+ h, err := p.forge.store.Passkeys().UserHandle(p.ctx)
104+ require.NoError(p.t, err)
105+ return h
106+}
107+
108+// login signs in with the authenticator; it returns the answer of finish.
109+func (p *passkeyHarness) login() page {
110+ p.t.Helper()
111+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
112+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
113+ var br beginReply
114+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
115+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
116+ require.NoError(p.t, err)
117+ resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
118+ return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
119+}
120+
121+// AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs
122+// out, signs in with the passkey, sees it in the CLI, and removing it ends
123+// every session.
124+func TestPasskeyEndToEnd(t *testing.T) {
125+ p := newPasskeyHarness(t)
126+ p.seed("secret", domain.VisibilityPrivate, "notes.txt")
127+
128+ // The server announces passkeys, and the pages link to them.
129+ ver := p.cli(t, "", "version", "--json").ok(t)
130+ assert.Contains(t, ver.stdout, `"passkeys"`)
131+ assert.Empty(t, ver.stderr, "no feature is missing")
132+ login := p.b.get("/-/login", nil)
133+ require.Equal(t, http.StatusOK, login.status)
134+ assert.Contains(t, login.body, `data-passkey="login"`)
135+
136+ // Anonymous: the list is a 404, registration is refused.
137+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
138+ assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", nil).status)
139+
140+ // Sign in with the CLI link (the way in before any passkey), add a passkey.
141+ link := p.cli(t, adminToken, "web", "login").ok(t)
142+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
143+ require.Equal(t, http.StatusSeeOther, pg.status, pg.body)
144+ token := pg.cookie(sessionCookie).Value
145+
146+ fin := p.register(token, "Test phone")
147+ require.Equal(t, http.StatusNoContent, fin.status, fin.body)
148+
149+ list := p.b.get("/-/passkeys", as(token))
150+ require.Equal(t, http.StatusOK, list.status)
151+ assert.Contains(t, list.body, "Test phone")
152+ assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control"))
153+
154+ // The same credential cannot be added twice.
155+ dup := p.register(token, "again")
156+ assert.Equal(t, http.StatusConflict, dup.status, dup.body)
157+
158+ // AC2: an anonymous browser signs in with the passkey: same cookie.
159+ res := p.login()
160+ require.Equal(t, http.StatusNoContent, res.status, res.body)
161+ var ck *http.Cookie
162+ for _, c := range (&http.Response{Header: res.hdr}).Cookies() {
163+ if c.Name == sessionCookie {
164+ ck = c
165+ }
166+ }
167+ require.NotNil(t, ck)
168+ assert.True(t, ck.Secure)
169+ assert.True(t, ck.HttpOnly)
170+ assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
171+ assert.Equal(t, 12*3600, ck.MaxAge)
172+ home := p.b.get("/", as(ck.Value))
173+ assert.Contains(t, home.body, "secret", "the passkey session is the owner's")
174+
175+ // AC3: the CLI lists it, with its use; the id removes it.
176+ out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t)
177+ var listed []struct {
178+ ID string `json:"id"`
179+ Name string `json:"name"`
180+ LastUsedAt *string `json:"last_used_at"`
181+ Synced bool `json:"synced"`
182+ }
183+ require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed))
184+ require.Len(t, listed, 1)
185+ assert.Equal(t, "Test phone", listed[0].Name)
186+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
187+ assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
188+
189+ rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t)
190+ assert.Contains(t, rm.stderr, "every browser is signed out")
191+ for _, tok := range []string{token, ck.Value} {
192+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
193+ }
194+ // The removed passkey no longer signs in, and is not there to remove again.
195+ res = p.login()
196+ assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
197+ assert.Empty(t, res.hdr.Values("Set-Cookie"))
198+ again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID)
199+ require.Error(t, again.err)
200+ assert.Contains(t, again.err.Error(), "not found")
201+}
202+
203+// A passkey that signs in is refused when the origin is wrong, and nothing is set.
204+func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) {
205+ p := newPasskeyHarness(t)
206+ link := p.cli(t, adminToken, "web", "login").ok(t)
207+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
208+ token := pg.cookie(sessionCookie).Value
209+ require.Equal(t, http.StatusNoContent, p.register(token, "k").status)
210+
211+ // The Origin header is wrong: the gate refuses before anything else.
212+ req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader(""))
213+ require.NoError(t, err)
214+ req.Header.Set("Origin", "https://evil.example")
215+ resp, err := http.DefaultClient.Do(req)
216+ require.NoError(t, err)
217+ _ = resp.Body.Close()
218+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
219+
220+ // The authenticator answers for another relying party.
221+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
222+ var br beginReply
223+ require.NoError(t, json.Unmarshal([]byte(begin.body), &br))
224+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
225+ require.NoError(t, err)
226+ evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"}
227+ resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts)
228+ fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)})
229+ assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body)
230+ assert.Empty(t, fin.hdr.Values("Set-Cookie"))
231+
232+ // The ceremony is burnt: even the right answer is refused afterwards.
233+ good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
234+ fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)})
235+ assert.Equal(t, http.StatusUnauthorized, fin.status)
236+}
237+
238+// AC4: on the harness's http public URL (not https), passkeys are off: no
239+// route, no link, no feature, and the RPCs say so.
240+func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) {
241+ c := newCLIHarness(t) // http://example.test
242+ b := browser{c.harness}
243+ assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status)
244+ assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route")
245+ token := c.signIn(t)
246+ assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status)
247+ home := b.get("/", as(token))
248+ assert.NotContains(t, home.body, "/-/passkeys")
249+ assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`)
250+
251+ res := c.cli(t, adminToken, "web", "passkey", "list")
252+ require.Error(t, res.err)
253+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
254+ ver := c.cli(t, "", "version", "--json").ok(t)
255+ assert.NotContains(t, ver.stdout, `"features": [`+"\n"+` "passkeys"`)
256+}
M internal/app/web_session_test.go +27 -0
@@ -596,3 +596,30 @@ func TestWarnSharedClientKey(t *testing.T) {
596596 })
597597 }
598598 }
599+
600+// AC4: passkeys exist only where WebAuthn can work: an https public URL (or
601+// localhost) with a DNS name as host.
602+func TestPasskeysAvailable(t *testing.T) {
603+ t.Parallel()
604+ for in, want := range map[string]struct {
605+ ok bool
606+ reason string
607+ }{
608+ "https://git.example.com": {true, ""},
609+ "https://git.example.com:8443": {true, ""},
610+ "https://GIT.example.com/": {true, ""},
611+ "http://localhost:7341": {true, ""},
612+ "http://127.0.0.1:7341": {false, "public URL host is an IP address"},
613+ "http://[::1]:7341": {false, "public URL host is an IP address"},
614+ "https://203.0.113.7": {false, "public URL host is an IP address"},
615+ "https://[2001:db8::1]": {false, "public URL host is an IP address"},
616+ "http://git.example.com": {false, "needs https"},
617+ "http://192.168.1.2:7341": {false, "needs https"},
618+ "": {false, "public URL has no host"},
619+ "https://": {false, "public URL has no host"},
620+ } {
621+ ok, reason := passkeysAvailable(in)
622+ assert.Equal(t, want.ok, ok, in)
623+ assert.Equal(t, want.reason, reason, in)
624+ }
625+}
@@ -596,3 +596,30 @@ func TestWarnSharedClientKey(t *testing.T) {
596 })596 })
597 }597 }
598 }598 }
599+
600+// AC4: passkeys exist only where WebAuthn can work: an https public URL (or
601+// localhost) with a DNS name as host.
602+func TestPasskeysAvailable(t *testing.T) {
603+ t.Parallel()
604+ for in, want := range map[string]struct {
605+ ok bool
606+ reason string
607+ }{
608+ "https://git.example.com": {true, ""},
609+ "https://git.example.com:8443": {true, ""},
610+ "https://GIT.example.com/": {true, ""},
611+ "http://localhost:7341": {true, ""},
612+ "http://127.0.0.1:7341": {false, "public URL host is an IP address"},
613+ "http://[::1]:7341": {false, "public URL host is an IP address"},
614+ "https://203.0.113.7": {false, "public URL host is an IP address"},
615+ "https://[2001:db8::1]": {false, "public URL host is an IP address"},
616+ "http://git.example.com": {false, "needs https"},
617+ "http://192.168.1.2:7341": {false, "needs https"},
618+ "": {false, "public URL has no host"},
619+ "https://": {false, "public URL has no host"},
620+ } {
621+ ok, reason := passkeysAvailable(in)
622+ assert.Equal(t, want.ok, ok, in)
623+ assert.Equal(t, want.reason, reason, in)
624+ }
625+}

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