Files
Felis/internal/api/passkey_uv_test.go
T

114 lines
4.7 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
)
// uvCases are the ways an assertion can lack user verification (#9): the credential
// was bound presence-only (a pre-0009 row, or a future UV=preferred enrollment), this
// assertion itself did not verify the user, or it names a credential the account does
// not hold. Each must be refused at every door that grants or confirms a session.
var uvCases = []struct {
name string
storedUV bool
assertUV bool
assertCred string
}{
{"credential bound without UV", false, true, ""},
{"assertion without UV", true, false, ""},
{"credential not bound to the account", true, true, "cred-elsewhere"},
}
func TestPasskeyLoginRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
row := repo.passkeyCreds["row1"]
row.UserVerified = tc.storedUV
repo.passkeyCreds["row1"] = row
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
v.assertion.CredentialID = tc.assertCred
}
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader)
assertUVRefused(t, w, repo, "player")
})
}
}
func TestPasskeyDiscoverableLoginRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
row := repo.passkeyCreds["row1"]
row.UserVerified = tc.storedUV
repo.passkeyCreds["row1"] = row
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
v.assertion.CredentialID = tc.assertCred
}
eh := api.ExternalHandler()
loginID := beginDiscoverableLogin(t, eh)
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
assertUVRefused(t, w, repo, "player")
})
}
}
func TestReauthByPasskeyRefusesUnverifiedUser(t *testing.T) {
for _, tc := range uvCases {
t.Run(tc.name, func(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
f.repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1",
UserVerified: tc.storedUV, CreatedAt: frozenNow}
pv.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
if tc.assertCred != "" {
pv.assertion.CredentialID = tc.assertCred
}
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
f.repo.audits = nil
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"cred-1"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("an unverified assertion marked the session reauthenticated")
}
if got := f.repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
}
if n := len(f.repo.audits); n != 1 || f.repo.audits[0].Action != "auth.passkey_uv_rejected" {
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit, got %+v", f.repo.audits)
}
})
}
}
// assertUVRefused checks a sign-in door's refusal: the opaque envelope every finish
// failure shares, no session, the stored credential untouched, and one distinct audit
// under the account.
func assertUVRefused(t *testing.T, rec *httptest.ResponseRecorder, repo *fakeRepo, actor string) {
t.Helper()
if rec.Code != http.StatusBadRequest || decodeErr(t, rec) != "passkey_login_invalid" {
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", rec.Code, rec.Body.String())
}
if len(repo.sessions) != 0 || len(rec.Result().Cookies()) != 0 {
t.Fatalf("refusal minted a session: sessions=%d cookies=%v", len(repo.sessions), rec.Result().Cookies())
}
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
}
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_uv_rejected" || repo.audits[0].Actor != actor {
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit by %s, got %+v", actor, repo.audits)
}
}