feat(app): wire passkeys when the public URL allows them
- commit
- 21bc4dd7c4b229184c9ab212991d1d93838fc6bf
- author
- brice · · 2026-10-12 15:16 UTC
- parent
- 26943f5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 48 | 48 | eventBuffer int | |
| 49 | 49 | // revalidateEvery overrides how often a watcher's token is re-checked. | |
| 50 | 50 | revalidateEvery time.Duration | |
| 51 | + | // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set. | |
| 52 | + | publicURL string | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | // 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 | 68 | config.EnvGit: gitBin, | |
| 67 | 69 | config.EnvAddr: "127.0.0.1:1", | |
| 68 | 70 | } | |
| 71 | + | if o.publicURL != "" { | |
| 72 | + | env[config.EnvPublicURL] = o.publicURL | |
| 73 | + | } | |
| 69 | 74 | if o.trustedProxies != "" { | |
| 70 | 75 | env[config.EnvTrustedProxies] = o.trustedProxies | |
| 71 | 76 | } | |
| @@ -48,6 +48,8 @@ type harnessOpts struct { | |||||
| 48 | eventBuffer int | 48 | 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.Duration | 50 | 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 socket | 55 | // 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.trustedProxies | 75 | env[config.EnvTrustedProxies] = o.trustedProxies | ||
| 71 | } | 76 | } | ||
M internal/app/forge.go +26 -3
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 25 | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 26 | 26 | "bnema.dev/ermite/internal/adapters/web" | |
| 27 | + | "bnema.dev/ermite/internal/adapters/webauthn" | |
| 27 | 28 | "bnema.dev/ermite/internal/clientlimit" | |
| 28 | 29 | "bnema.dev/ermite/internal/config" | |
| 29 | 30 | "bnema.dev/ermite/internal/domain" | |
| @@ -73,9 +74,11 @@ type forge struct { | |||
| 73 | 74 | MergeRequests *usecase.MergeRequests | |
| 74 | 75 | SigningKey *usecase.SigningKey | |
| 75 | 76 | 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 | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 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 | 211 | 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 | 227 | // One budget of failed authentications per client, for Git, the API and | |
| 212 | 228 | // the web login. | |
| 213 | 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 | 236 | f.API = rpc.New(rpc.Config{ | |
| 215 | 237 | Auth: f.Auth, | |
| 216 | 238 | Repos: f.Repos, | |
| @@ -220,6 +242,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 220 | 242 | MergeRequests: f.MergeRequests, | |
| 221 | 243 | SigningKey: f.SigningKey, | |
| 222 | 244 | Sessions: f.Sessions, | |
| 245 | + | Passkeys: apiPasskeys, | |
| 223 | 246 | Version: serverVersion(), | |
| 224 | 247 | TrustedProxies: cfg.TrustedProxies, | |
| 225 | 248 | 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.MergeRequests | 74 | MergeRequests *usecase.MergeRequests | ||
| 74 | SigningKey *usecase.SigningKey | 75 | SigningKey *usecase.SigningKey | ||
| 75 | Sessions *usecase.Sessions | 76 | Sessions *usecase.Sessions | ||
| 76 | - | EventLog *usecase.Events | 77 | + | // Passkeys is nil when passkeys are not available (passkeysAvailable). |
| 77 | - | API *rpc.API | 78 | + | Passkeys *usecase.Passkeys |
| 78 | - | Bus ports.EventBus | 79 | + | 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, err | 211 | 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 and | 227 | // 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 ( | |||
| 29 | 29 | // checks it with sessions); every other request is anonymous. The web passes | |
| 30 | 30 | // the principal of the request to each use case call itself. | |
| 31 | 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 | 38 | return web.New(web.Config{ | |
| 33 | 39 | Repos: f.Repos, | |
| 34 | 40 | Browse: f.Browse, | |
| @@ -39,6 +45,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 39 | 45 | Version: serverVersion(), | |
| 40 | 46 | ||
| 41 | 47 | Sessions: webSessions{f.Sessions}, | |
| 48 | + | Passkeys: passkeys, | |
| 42 | 49 | Limiter: limiter, | |
| 43 | 50 | TrustedProxies: cfg.TrustedProxies, | |
| 44 | 51 | }) | |
| @@ -61,6 +68,25 @@ func sessionsNeedHTTPS(publicURL string) bool { | |||
| 61 | 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 | 90 | // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will | |
| 65 | 91 | // not work on an http public URL. It does not stop the server: the pages, | |
| 66 | 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 | 157 | 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 | 193 | // webRoutes mounts the web on the patterns it serves. | |
| 135 | 194 | func webRoutes(h http.Handler) []httpserver.Route { | |
| 136 | 195 | 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 passes | 29 | // 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 will | 90 | // 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, nil | 157 | 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) { | |||
| 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 | + | } | |
| @@ -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 | + | } | |||