202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/kristianwind/verdande/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// What can be tested without an authenticator, which is most of the surface that
|
||
|
|
// actually goes wrong.
|
||
|
|
//
|
||
|
|
// The signature itself cannot be produced here — that needs a real device or a
|
||
|
|
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
|
||
|
|
// What is testable is everything around it: who may reach these endpoints, that a
|
||
|
|
// challenge is answerable exactly once, that one account's challenge cannot be
|
||
|
|
// answered by another, and that an expired one is refused. Those are the parts a
|
||
|
|
// mistake would make silently permissive.
|
||
|
|
|
||
|
|
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
|
||
|
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
token := ts.apiToken(t, admin.ID)
|
||
|
|
|
||
|
|
// A leaked API token must not be able to add its own way in: that would turn a
|
||
|
|
// theft into a tenancy.
|
||
|
|
for _, c := range []struct{ method, path string }{
|
||
|
|
{"GET", "/api/v1/auth/passkeys"},
|
||
|
|
{"POST", "/api/v1/auth/passkeys/register/begin"},
|
||
|
|
{"POST", "/api/v1/auth/passkeys/register/finish"},
|
||
|
|
} {
|
||
|
|
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
resp, err := (&http.Client{}).Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusForbidden {
|
||
|
|
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Beginning a registration hands back options and a challenge id, and the options
|
||
|
|
// carry the things that make this a passkey rather than a second factor.
|
||
|
|
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
|
||
|
|
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
if body["challenge_id"] == "" || body["challenge_id"] == nil {
|
||
|
|
t.Fatal("no challenge id came back")
|
||
|
|
}
|
||
|
|
|
||
|
|
options, _ := body["options"].(map[string]any)
|
||
|
|
publicKey, _ := options["publicKey"].(map[string]any)
|
||
|
|
if publicKey == nil {
|
||
|
|
t.Fatalf("no publicKey in the options: %v", options)
|
||
|
|
}
|
||
|
|
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
|
||
|
|
if selection["residentKey"] != "preferred" {
|
||
|
|
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
|
||
|
|
"with no email typed", selection["residentKey"])
|
||
|
|
}
|
||
|
|
if selection["userVerification"] != "preferred" {
|
||
|
|
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
|
||
|
|
"stand in for the password", selection["userVerification"])
|
||
|
|
}
|
||
|
|
// The user handle must be the account id rather than the email: it is written
|
||
|
|
// into the credential and cannot be changed, so an email there would orphan
|
||
|
|
// every key the moment somebody changed their address.
|
||
|
|
user, _ := publicKey["user"].(map[string]any)
|
||
|
|
if user["name"] != "kristian@example.dk" {
|
||
|
|
t.Errorf("user.name = %v", user["name"])
|
||
|
|
}
|
||
|
|
if id, _ := user["id"].(string); id == "" {
|
||
|
|
t.Error("no user handle in the options")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A challenge is answerable exactly once, and only by whom it was issued to.
|
||
|
|
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||
|
|
|
||
|
|
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||
|
|
challenge := begun["challenge_id"].(string)
|
||
|
|
|
||
|
|
// Somebody else's challenge, answered by this account. Refused before the
|
||
|
|
// signature is even looked at.
|
||
|
|
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||
|
|
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
// And it is spent: the rightful owner cannot use it either now, because taking
|
||
|
|
// it is what deleted it. That is deliberate — a challenge that survives a
|
||
|
|
// failed attempt is a challenge somebody can keep trying.
|
||
|
|
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||
|
|
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Logging in with a key needs no session and says nothing about who exists here.
|
||
|
|
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
|
||
|
|
// No session at all — a fresh client, which is the whole point.
|
||
|
|
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||
|
|
|
||
|
|
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
challenge := body["challenge_id"].(string)
|
||
|
|
|
||
|
|
options, _ := body["options"].(map[string]any)
|
||
|
|
publicKey, _ := options["publicKey"].(map[string]any)
|
||
|
|
// A discoverable login must not list credentials: that list would say which
|
||
|
|
// keys exist on this instance to anybody who asked.
|
||
|
|
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
|
||
|
|
if list, _ := allowed.([]any); len(list) > 0 {
|
||
|
|
t.Errorf("the login options list %d credentials; a discoverable login must not",
|
||
|
|
len(list))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A garbage answer is refused with the same message a wrong key would get.
|
||
|
|
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
|
||
|
|
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A key belongs to its owner, and nobody else can rename or remove it.
|
||
|
|
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||
|
|
|
||
|
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
// Written straight to the store: registering one properly needs a device.
|
||
|
|
key := &store.Passkey{
|
||
|
|
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
|
||
|
|
}
|
||
|
|
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||
|
|
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
|
||
|
|
t.Fatalf("the owner sees %d keys, want 1", len(keys))
|
||
|
|
}
|
||
|
|
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||
|
|
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
|
||
|
|
t.Errorf("somebody else sees %d of this account's keys", len(keys))
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, c := range []struct {
|
||
|
|
what, method string
|
||
|
|
body any
|
||
|
|
}{
|
||
|
|
{"rename", "PATCH", map[string]any{"name": "min nu"}},
|
||
|
|
{"delete", "DELETE", nil},
|
||
|
|
} {
|
||
|
|
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
|
||
|
|
if resp.StatusCode != http.StatusNotFound {
|
||
|
|
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
|
||
|
|
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
|
||
|
|
t.Errorf("the owner renaming: status %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||
|
|
t.Errorf("the owner deleting: status %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|