Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Offer passkeys through conditional UI and require discoverable credentials

ad79b7abded893d0771eaf80de555a1236f17474 · Keenan Nemetz · 3d ago · parent 25343521

Offer passkeys through conditional UI and require discoverable credentials

Start a usernameless ceremony when the login page advertises conditional
mediation, so the browser can offer a saved passkey in the account field. The
assertion resolves the account from the credential id and user handle;
username-first sign-in stays for browsers without autofill and for passkeys
created before discoverable credentials were required.

Register each new passkey with residentKey required, and record the autofill
flow and fallback in the account documentation.

Refs #213 (01M6SAK9STFGJ7PECY7ET8277N).

server/web/auth_hardening_test.go +64

@@ -6,6 +6,7 @@ package web
6 6
7 7 import (
8 8 "crypto/tls"
9 "encoding/json"
9 10 "fmt"
10 11 "net/http"
11 12 "net/http/httptest"
@@ -367,3 +368,66 @@ func TestDynamicPagesArePrivate(t *testing.T) {
367 368 t.Fatalf("Cache-Control = %q, want a private directive", got)
368 369 }
369 370 }
371
372 // TestLoginBeginSupportsDiscoverableSignIn: an empty account name starts the
373 // usernameless flow, so conditional UI has a challenge and the authenticator can
374 // return the account in the assertion.
375 func TestLoginBeginSupportsDiscoverableSignIn(t *testing.T) {
376 t.Parallel()
377 w := authWeb(t)
378 r := httptest.NewRequest("POST", "/auth/login/begin", strings.NewReader(`{}`))
379 r.RemoteAddr = "127.0.0.1:1234"
380 rw := httptest.NewRecorder()
381 w.LoginBegin(rw, r)
382 if rw.Code != http.StatusOK {
383 t.Fatalf("discoverable begin = %d %s, want 200", rw.Code, rw.Body.String())
384 }
385 var out struct {
386 PublicKey struct {
387 AllowCredentials []any `json:"allowCredentials"`
388 UserVerification string `json:"userVerification"`
389 } `json:"publicKey"`
390 }
391 if err := json.Unmarshal(rw.Body.Bytes(), &out); err != nil {
392 t.Fatalf("decode assertion request: %v", err)
393 }
394 if len(out.PublicKey.AllowCredentials) != 0 {
395 t.Fatalf("discoverable begin named %d credentials, want none", len(out.PublicKey.AllowCredentials))
396 }
397 if out.PublicKey.UserVerification != "required" {
398 t.Fatalf("userVerification = %q, want required", out.PublicKey.UserVerification)
399 }
400 }
401
402 // TestRegistrationRequiresDiscoverableCredentials: a passkey that autofill cannot
403 // find is not a passkey for this product, so new registrations ask for a resident
404 // key and user verification.
405 func TestRegistrationRequiresDiscoverableCredentials(t *testing.T) {
406 t.Parallel()
407 w := authWeb(t)
408 w.Cfg.OpenRegistration = true
409 r := httptest.NewRequest("POST", "/auth/register/begin", strings.NewReader(`{"username":"alice"}`))
410 r.RemoteAddr = "127.0.0.1:1234"
411 rw := httptest.NewRecorder()
412 w.RegisterBegin(rw, r)
413 if rw.Code != http.StatusOK {
414 t.Fatalf("register begin = %d %s, want 200", rw.Code, rw.Body.String())
415 }
416 var out struct {
417 PublicKey struct {
418 AuthenticatorSelection struct {
419 ResidentKey string `json:"residentKey"`
420 UserVerification string `json:"userVerification"`
421 } `json:"authenticatorSelection"`
422 } `json:"publicKey"`
423 }
424 if err := json.Unmarshal(rw.Body.Bytes(), &out); err != nil {
425 t.Fatalf("decode creation options: %v", err)
426 }
427 if out.PublicKey.AuthenticatorSelection.ResidentKey != "required" {
428 t.Fatalf("residentKey = %q, want required", out.PublicKey.AuthenticatorSelection.ResidentKey)
429 }
430 if out.PublicKey.AuthenticatorSelection.UserVerification != "required" {
431 t.Fatalf("userVerification = %q, want required", out.PublicKey.AuthenticatorSelection.UserVerification)
432 }
433 }