114 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|