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 | } | ||