This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authrecovery_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authrecovery"
|
||||
"gamertan.com/web/authsqlite"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
|
||||
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
random := &counterReader{}
|
||||
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := access.Policy{
|
||||
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
|
||||
Permissions: map[string]string{"account.recover": "Recover an organization member"},
|
||||
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
|
||||
}
|
||||
accessService, err := access.New(store, policy, access.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = accessService.Seed(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existingID := bytes.Repeat([]byte{7}, 32)
|
||||
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
|
||||
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
|
||||
t.Fatalf("non-owner assisted recovery err=%v", err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
|
||||
t.Fatalf("denied recovery changed password: %v", err)
|
||||
}
|
||||
|
||||
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
|
||||
if err != nil || loaded.ID != target.ID || grant == "" {
|
||||
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
|
||||
}
|
||||
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
t.Fatalf("old session survived assisted recovery issue: %v", err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old password survived assisted recovery issue: %v", err)
|
||||
}
|
||||
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
|
||||
if err != nil || len(credentials) != 0 {
|
||||
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
|
||||
}
|
||||
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old recovery path survived issue: %v", err)
|
||||
}
|
||||
|
||||
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
|
||||
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
|
||||
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||
}
|
||||
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
|
||||
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
|
||||
t.Fatalf("replacement password unavailable: %v", err)
|
||||
}
|
||||
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
|
||||
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
|
||||
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
|
||||
}
|
||||
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
|
||||
t.Fatalf("assisted grant replay err=%v", err)
|
||||
}
|
||||
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
|
||||
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
|
||||
}
|
||||
audits, err := accessService.Audit(t.Context(), home.ID, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seenIssue, seenComplete := false, false
|
||||
for _, audit := range audits {
|
||||
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
|
||||
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
|
||||
}
|
||||
if !seenIssue || !seenComplete {
|
||||
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user