Compare commits

...
Author SHA1 Message Date
gamertan 6f0b597943 Add owner-assisted account recovery
verify / verify (push) Successful in 3m39s
2026-09-03 23:56:42 -04:00
gamertan 59827bf641 Add optimistic membership lifecycle
verify / verify (push) Successful in 3m38s
2026-09-03 23:22:19 -04:00
gamertan fe6bd94c9a Add atomic organization role administration
verify / verify (push) Successful in 3m39s
2026-09-03 22:51:53 -04:00
gamertan 17bd9453e2 Allow explicit local WebAuthn ports
verify / verify (push) Successful in 3m35s
2026-09-03 22:30:23 -04:00
gamertan d8b09c8ae5 Reject malformed PDF media uploads
verify / verify (push) Successful in 3m38s
2026-09-03 13:47:56 -04:00
gamertan 95d50f0888 Complete passkey recovery transaction
verify / verify (push) Successful in 3m37s
2026-09-03 13:09:22 -04:00
gamertan 1f54c75501 Add atomic initial owner bootstrap
verify / verify (push) Successful in 3m33s
2026-09-03 12:50:16 -04:00
gamertan 277cffed8c Bind passkey enrollment to authenticated user
verify / verify (push) Successful in 3m33s
2026-09-03 12:40:20 -04:00
30 changed files with 2587 additions and 68 deletions
+92
View File
@@ -2,6 +2,98 @@
# Changelog
## v0.1.0-preview.18 — 2026-09-04
- Add owner-assisted account recovery for a documented human-review path when
normal password, passkey, and recovery-code authentication is unavailable.
Issuance requires an active direct organization owner and returns a bounded,
single-use, 15-minute secret while persisting and auditing only its digest.
- Invalidate the recovered member's existing password, passkeys, recovery
codes, sessions, ceremonies, and older recovery grants when the reviewed
enrollment is issued. Completion atomically installs one replacement
password, passkey, and recovery-code set without issuing a normal session.
- Keep identity and organization-visible recovery audits in the same SQLite
transactions as their credential changes, and document the application
boundary for fresh passkey authorization, secret-fragment delivery, and
human evidence review.
## v0.1.0-preview.17 — 2026-09-04
- Add optimistic organization-membership suspension, reactivation, and
removal for fresh-authentication administration flows. The exact displayed
membership state is rechecked after acquiring the SQLite write lock, so a
concurrent or stale ceremony fails without changing access or writing an
audit event.
- Keep membership lifecycle consequences transactional: suspension removes
team membership, removal also revokes direct bindings, reactivation does not
silently restore former teams, and every successful change appends its
organization-visible audit before commit.
- Strengthen last-owner protection to require another active direct owner
whose platform account is also active. Existing storage adapters retain the
legacy interface; security-sensitive applications fail closed unless their
repository implements the optimistic lifecycle extension.
## v0.1.0-preview.16 — 2026-09-03
- Add bounded organization-member and direct user-role listings for
application-owned access administration pages. Direct listings deliberately
exclude team and narrower resource grants rather than flattening distinct
authority into one apparent role.
- Add atomic direct-role replacement with exact expected-binding checks,
transactional access audit, active-member validation, and final active
direct-owner protection. SQLite serializes competing replacements so stale
administration fails with a stable conflict instead of partially applying.
- Record the Gamertan administration dogfood boundary: applications authorize
the route and fresh passkey assertion, while Foundations owns the reusable
storage transaction and invariants.
## v0.1.0-preview.15 — 2026-09-03
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin
port for `localhost` and reserved `.test` relying-party IDs. The configured
origin remains exact, production origins remain portless by default, and
malformed, default, non-canonical, zero, or out-of-range ports fail closed.
- Record the Gamertan local-Caddy dogfood pressure that required this explicit
development boundary without weakening cross-origin ceremony rejection.
## v0.1.0-preview.14 — 2026-09-03
- Reject header-only, truncated, and structurally invalid PDF uploads in the
bounded media preparer. Accepted attachments now require a supported PDF
version, terminal EOF marker, numeric in-range `startxref`, and either a
traditional xref/trailer or xref-stream object at the declared offset.
- Keep PDF handling storage-neutral and non-rendering: applications still own
authorization, reference tracking, attachment disposition, and lifecycle.
## v0.1.0-preview.13 — 2026-09-03
- Complete the password-plus-recovery-code flow with a short-lived restricted
grant bound into a replacement-passkey ceremony. Completion atomically
consumes the grant, stores the verified passkey, replaces every recovery
code, revokes any intervening sessions and ceremonies, and records both
audits without issuing a normal session.
- Keep failed completion retryable until grant expiry: a duplicate credential
or other transaction failure rolls back grant consumption and recovery-code
replacement, while a mismatched WebAuthn binding consumes only the affected
ceremony.
## v0.1.0-preview.12 — 2026-09-03
- Add a root-local bootstrap transaction that creates the first passkey-only
application owner, non-personal organization, active membership, direct
owner binding, one-time enrollment digest, and secret-free audit records
atomically.
- Fail closed and roll back the entire bootstrap when the application has not
seeded the configured owner role. The raw enrollment token is returned only
after commit and never enters repository state or audit records.
## v0.1.0-preview.11 — 2026-09-03
- Add expected-user completion for authenticated self-service passkey
enrollment. A mismatched ceremony is consumed and fails before credential
persistence, closing an authorization seam found while dogfooding Gamertan's
account security page.
## v0.1.0-preview.10 — 2026-09-03
- Add atomic public-account registration with required canonical email,
+8 -4
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.10`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.18`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -40,7 +40,8 @@ packages you import.
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
@@ -56,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.10
go get gamertan.com/web@v0.1.0-preview.18
go mod verify
```
An application may name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
```
The version belongs to the `gamertan.com/web` module. See the
@@ -101,6 +102,9 @@ JSONL logging.
request context without owning login routes or pages.
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
enrollment, operation-bound fresh approval, and bounded recovery.
- [`authrecovery`](authrecovery) supports printable self-service recovery and
a separate owner-assisted flow that atomically replaces compromised account
credentials while writing both identity and organization-visible audits.
- [`organizations`](organizations) and [`access`](access) keep platform
operation separate from organization-data authority while supporting teams,
invitations, scoped roles, and audited temporary access.
+83 -1
View File
@@ -18,6 +18,9 @@ import (
)
var (
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
@@ -116,6 +119,8 @@ type Repository interface {
Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, error)
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error
@@ -125,6 +130,7 @@ type Repository interface {
type Options struct {
Random io.Reader
Now func() time.Time
OwnerRole string
}
type Service struct {
@@ -132,6 +138,7 @@ type Service struct {
policy Policy
random io.Reader
now func() time.Time
ownerRole string
}
func New(repository Repository, policy Policy, options Options) (*Service, error) {
@@ -147,7 +154,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
if options.OwnerRole != "" {
if _, ok := policy.Roles[options.OwnerRole]; !ok {
return nil, errors.New("access: owner role is unknown")
}
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
}
func (service *Service) Seed(ctx context.Context) error {
@@ -183,6 +195,62 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
return binding, nil
}
// OrganizationUserBindings lists active, direct, organization-wide user role
// bindings. Team and narrower project/environment/service grants remain
// separate because an administration screen must not silently flatten their
// authority into one apparent role.
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("access: invalid organization binding query")
}
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
}
type OrganizationUserRoleChange struct {
OrganizationID string
UserID string
Role string
ActorUserID string
RequestID string
ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also protects the final active
// direct owner in the same transaction.
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement")
}
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
return Binding{}, errors.New("access: invalid organization role replacement")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return Binding{}, err
}
bindingID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
now := service.now().UTC()
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
return Binding{}, err
}
return binding, nil
}
type Decision struct {
Allowed bool
Source string
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalBindingIDs(values []string) ([]string, error) {
if len(values) > 16 {
return nil, errors.New("access: invalid expected role bindings")
}
result := append([]string(nil), values...)
sort.Strings(result)
for index, value := range result {
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
return nil, errors.New("access: invalid expected role bindings")
}
}
return result, nil
}
func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
+60
View File
@@ -4,6 +4,8 @@ package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
@@ -56,9 +58,57 @@ func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
}
}
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
now := time.Unix(2000, 0).UTC()
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
t.Fatal("unknown owner role accepted")
}
repository := &repositoryStub{}
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
OrganizationID: "org-12345678",
UserID: "user-12345678",
Role: "viewer",
ActorUserID: "user-87654321",
RequestID: "request-12345678",
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
})
if err != nil {
t.Fatal(err)
}
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
t.Fatalf("binding=%+v", binding)
}
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
}
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
}
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
t.Fatal("duplicate expected binding accepted")
}
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
if err != nil {
t.Fatal(err)
}
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
t.Fatalf("missing owner role err=%v", err)
}
}
type repositoryStub struct {
bindings []Binding
breakGlass []BreakGlass
organizationUser []Binding
replacedExpected []string
replacedBinding Binding
replacedOwnerRole string
replacedAccessAudit AuditEvent
}
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
@@ -67,6 +117,16 @@ func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
return repository.bindings, nil
}
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
return repository.organizationUser, nil
}
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
repository.replacedExpected = append([]string(nil), expected...)
repository.replacedBinding = binding
repository.replacedOwnerRole = ownerRole
repository.replacedAccessAudit = audit
return nil
}
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
repository.breakGlass = []BreakGlass{grant}
return nil
+157
View File
@@ -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)
}
}
+299 -5
View File
@@ -16,7 +16,9 @@ import (
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const DefaultCodeCount = 10
@@ -24,6 +26,9 @@ const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
)
type Grant struct {
@@ -39,6 +44,79 @@ type Repository interface {
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
}
// PasskeyRepository adds the transactional boundary required to finish a
// password-plus-recovery-code flow without issuing a normal session.
type PasskeyRepository interface {
Repository
RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
}
// AssistedGrant is the digest-only authority created by an organization
// owner after a human recovery review. The plaintext token is returned once
// to the caller and never persisted or audited.
type AssistedGrant struct {
Digest [32]byte
OrganizationID, UserID string
IssuedByUserID string
CreatedAt, ExpiresAt time.Time
}
// AssistedIssue binds an owner-reviewed recovery to one organization member.
// Reason is deliberately bounded and must not contain credential material.
type AssistedIssue struct {
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
}
// AssistedRepository provides the two transactional boundaries for delegated
// recovery. Issuance invalidates all existing account authenticators and
// sessions while recording both identity and organization-visible audits.
// Completion consumes the grant exactly once and installs the replacement
// password, passkey, and recovery-code set atomically.
type AssistedRepository interface {
Repository
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
}
// AssistedCompletion contains only the password hash, public passkey
// credential, digest-only recovery codes, and secret-free audit material.
type AssistedCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
PasswordHash string
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
type Passkeys interface {
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
// PasskeyCompletion contains the public credential, digest-only replacement
// codes, and secret-free audits committed after a recovery ceremony.
type PasskeyCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
CompletedAt time.Time
}
// PasskeyFinishResult returns the verified credential and the new plaintext
// recovery codes. Applications must display the codes once and retain none.
type PasskeyFinishResult struct {
Credential authwebauthn.Credential
RecoveryCodes []string
}
type PasswordVerifier interface {
VerifyPassword(context.Context, string, string) (auth.User, error)
}
@@ -48,6 +126,9 @@ type Options struct {
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
AssistedGrantLifetime time.Duration
OwnerRole string
Passkeys Passkeys
}
type Service struct {
@@ -57,6 +138,9 @@ type Service struct {
now func() time.Time
count int
grantTTL time.Duration
assistedTTL time.Duration
ownerRole string
passkeys Passkeys
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
@@ -75,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
if options.GrantLifetime == 0 {
options.GrantLifetime = 10 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
if options.AssistedGrantLifetime == 0 {
options.AssistedGrantLifetime = 15 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
return nil, errors.New("authrecovery: invalid recovery policy")
}
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime}, nil
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
}
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
// token. The repository immediately invalidates the target's previous
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
// reviewed recovery cannot race an older authenticator.
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
repository, ok := service.repository.(AssistedRepository)
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
input.RequestID = strings.TrimSpace(input.RequestID)
input.Reason = strings.TrimSpace(input.Reason)
if !ok || service.passkeys == nil || service.ownerRole == "" {
return auth.User{}, "", ErrPasskeyUnavailable
}
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
}
raw, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
authAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
if err != nil {
return auth.User{}, "", err
}
return user, raw, nil
}
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
// session. The grant remains reusable for ceremony restart until completion or
// expiry; only completion consumes it.
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return authwebauthn.BeginResult{}, ErrAssistedNotFound
}
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
}
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
// that installs every replacement credential and both audit trails. No normal
// session is issued; the recovered user signs in with the new credentials.
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return PasskeyFinishResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return PasskeyFinishResult{}, ErrAssistedNotFound
}
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return PasskeyFinishResult{}, err
}
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
if err != nil {
return PasskeyFinishResult{}, err
}
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return PasskeyFinishResult{}, err
}
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
if verified.UserID != user.ID {
return errors.New("authrecovery: assisted recovery identity mismatch")
}
completedAt := service.now().UTC()
recoveryAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
accessAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
})
if err != nil {
return PasskeyFinishResult{}, err
}
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
}
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
@@ -133,10 +327,74 @@ func (service *Service) Begin(ctx context.Context, identifier, password, code st
}
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
if len(raw) < 32 || len(raw) > 128 {
return auth.User{}, ErrGrantNotFound
digest, err := grantDigest(raw)
if err != nil {
return auth.User{}, err
}
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC())
}
// BeginPasskey starts a ceremony only for a live restricted recovery grant.
// The raw grant remains application-held so a failed or interrupted ceremony
// can be restarted until the grant expires.
func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
repository, ok := service.repository.(PasskeyRepository)
if !ok || service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return authwebauthn.BeginResult{}, err
}
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
}
// FinishPasskey consumes the grant only inside the transaction that stores the
// verified passkey and a fresh recovery-code set. It never issues a session.
func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) {
repository, ok := service.repository.(PasskeyRepository)
if !ok || service.passkeys == nil {
return PasskeyFinishResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return PasskeyFinishResult{}, err
}
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return PasskeyFinishResult{}, err
}
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return PasskeyFinishResult{}, err
}
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
if verified.UserID != user.ID {
return errors.New("authrecovery: recovery identity mismatch")
}
completedAt := service.now().UTC()
auditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt}
return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{
GrantDigest: digest,
Credential: verified,
RecoveryDigests: digests,
PasskeyAudit: passkeyAudit,
RecoveryAudit: recoveryAudit,
CompletedAt: completedAt,
})
})
if err != nil {
return PasskeyFinishResult{}, err
}
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
}
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
@@ -173,6 +431,16 @@ func DigestCode(code string) ([32]byte, error) {
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
}
func grantDigest(raw string) ([32]byte, error) {
if len(raw) < 32 || len(raw) > 128 {
return [32]byte{}, ErrGrantNotFound
}
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
return [32]byte{}, ErrGrantNotFound
}
return sha256.Sum256([]byte(raw)), nil
}
func token(random io.Reader, size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
@@ -180,3 +448,29 @@ func token(random io.Reader, size int) (string, error) {
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func opaqueID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
func safeRole(value string) bool {
if len(value) < 1 || len(value) > 96 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
+112
View File
@@ -3,6 +3,10 @@
package authrecovery_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"strings"
@@ -12,6 +16,8 @@ import (
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
@@ -61,6 +67,112 @@ func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
}
}
func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) {
now := time.Date(2026, 9, 3, 13, 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)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"})
if 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: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil {
t.Fatal(err)
}
passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys})
if err != nil {
t.Fatal(err)
}
oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
if err != nil {
t.Fatal(err)
}
_, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0])
if err != nil {
t.Fatal(err)
}
begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey")
if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant {
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
}
if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil {
t.Fatal("duplicate credential unexpectedly committed")
}
if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil {
t.Fatalf("failed completion consumed recovery grant: %v", err)
}
lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
passkeys.credentialID = bytes.Repeat([]byte{8}, 32)
result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`))
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) {
t.Fatalf("result=%+v err=%v", result, err)
}
if passkeys.finishBinding != grant {
t.Fatal("finish ceremony was not bound to the restricted recovery grant")
}
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
t.Fatalf("completed grant replay err=%v", err)
}
if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session created during recovery survived completion: %v", err)
}
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old recovery-code set survived completion: %v", err)
}
if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" {
t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr)
}
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
if err != nil || len(credentials) != 2 {
t.Fatalf("credentials=%+v err=%v", credentials, err)
}
}
type passkeyRecoveryStub struct {
now time.Time
userID string
credentialID []byte
beginBinding string
finishBinding string
}
func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
stub.userID = userID
stub.beginBinding = string(binding)
return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil
}
func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
stub.finishBinding = string(binding)
encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}})
if err != nil {
return authwebauthn.Credential{}, err
}
credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now}
audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now}
if err = commit(ctx, credential, audit); err != nil {
return authwebauthn.Credential{}, err
}
return credential, nil
}
type counterReader struct{ value byte }
func (reader *counterReader) Read(target []byte) (int, error) {
+154
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"slices"
"time"
"gamertan.com/web/access"
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
return result, rows.Err()
}
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization binding query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
WHERE b.organization_id=? AND b.subject_kind='user'
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL
ORDER BY b.subject_id,b.role_name,b.id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]access.Binding, 0)
for rows.Next() {
var binding access.Binding
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.SubjectKind = access.User
binding.Scope = access.Scope{OrganizationID: organizationID}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
return errors.New("authsqlite: invalid organization role replacement")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the SQLite write lock before reading the optimistic binding set.
// This serializes competing role replacements so the loser observes the
// committed binding IDs and returns ErrRoleChangeConflict instead of an
// ambiguous busy-snapshot error.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: role grantor is not active in organization")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
if active != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
if err != nil {
return err
}
var currentIDs []string
var currentRoles []string
for rows.Next() {
var id, role string
if err = rows.Scan(&id, &role); err != nil {
rows.Close()
return err
}
currentIDs = append(currentIDs, id)
currentRoles = append(currentRoles, role)
}
if err = rows.Err(); err != nil {
rows.Close()
return err
}
if err = rows.Close(); err != nil {
return err
}
if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict
}
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
return access.ErrRoleUnchanged
}
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
return err
}
if otherOwners == 0 {
return access.ErrLastOwner
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=?
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
return err
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
return false
}
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
return false
}
if len(expected) > 16 || !slices.IsSorted(expected) {
return false
}
for index, id := range expected {
if !opaqueID(id) || index > 0 && expected[index-1] == id {
return false
}
}
return true
}
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event")
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
// Take the SQLite write lock before checking owner authority so a role or
// membership mutation cannot race the reviewed recovery decision.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)
AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
if err != nil {
return auth.User{}, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return auth.User{}, authrecovery.ErrAssistedDenied
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authrecovery.ErrAssistedDenied
}
if err != nil {
return auth.User{}, err
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
return auth.User{}, err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, authAudit); err != nil {
return auth.User{}, err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
var grant authrecovery.AssistedGrant
var user auth.User
var created, expires, userCreated, userUpdated int64
var passwordChangeRequired, registrationPending int
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_assisted_recovery_grants g
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &userCreated, &userUpdated)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
if err != nil {
return authrecovery.AssistedGrant{}, auth.User{}, err
}
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
return grant, user, nil
}
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
credential := completion.Credential
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
return errors.New("authsqlite: invalid assisted recovery completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, digest := range completion.RecoveryDigests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid assisted recovery-code digest")
}
if _, duplicate := seen[digest]; duplicate {
return errors.New("authsqlite: duplicate assisted recovery-code digest")
}
seen[digest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var userID, organizationID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.ErrAssistedNotFound
}
if err != nil {
return err
}
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
return errors.New("authsqlite: assisted recovery identity mismatch")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
return err
}
if active != 1 {
return auth.ErrInactiveUser
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
for _, digest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/bootstrap"
)
// CreateInitialOwner commits the root-local bootstrap across identity,
// enrollment, organization, membership, owner access, and all audit records.
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
user := setup.User
organization := setup.Organization
membership := setup.Membership
binding := setup.OwnerBinding
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
!validOwnerBinding(binding, organization.ID, user.ID) ||
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
return errors.New("authsqlite: invalid initial owner bootstrap")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ bootstrap.Repository = (*Store)(nil)
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/bootstrap"
)
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
user, err := store.UserByID(t.Context(), created.User.ID)
if err != nil || user.Email != "cole@example.test" {
t.Fatalf("user=%+v err=%v", user, err)
}
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
if err != nil || organization.Personal || organization.Slug != "gamertan" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
t.Fatalf("memberships=%+v err=%v", memberships, err)
}
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
passkeyService := testBootstrapPasskeyService(t, store, now)
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
if err != nil || begin.CeremonyToken == "" {
t.Fatalf("begin=%+v err=%v", begin, err)
}
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("enrollment replay err=%v", err)
}
var authAudits, accessAudits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
t.Fatal(err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
t.Fatal(err)
}
if authAudits != 1 || accessAudits != 2 {
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
}
}
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
t.Fatal("bootstrap succeeded without seeded role")
}
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
var count int
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
}
}
}
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
t.Helper()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return service
}
+165 -3
View File
@@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
return result, rows.Err()
}
func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization member query")
}
rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at
FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=?
ORDER BY m.joined_at,m.user_id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]organizations.Membership, 0)
for rows.Next() {
var membership organizations.Membership
var joined int64
if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil {
return nil, err
}
membership.OrganizationID = organizationID
membership.JoinedAt = time.Unix(joined, 0).UTC()
result = append(result, membership)
}
return result, rows.Err()
}
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid team query")
@@ -425,6 +453,48 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use
return tx.Commit()
}
func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
if !validMembershipStatusChange(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
return err
}
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
if err != nil {
return err
}
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if input.Status == "suspended" {
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=? AND status=?`, input.Status, input.OrganizationID, input.UserID, input.ExpectedStatus)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if input.Status == "suspended" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
return err
}
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(userID) || !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrMembershipNotFound
@@ -456,6 +526,77 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
return tx.Commit()
}
func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
if !validMembershipRemoval(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil {
return err
}
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
if err != nil {
return err
}
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), input.OrganizationID, input.UserID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status=?`, input.OrganizationID, input.UserID, input.ExpectedStatus)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
// Acquire the SQLite write lock before reading the optimistic state. This
// makes a competing lifecycle transaction observe the committed winner.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, organizationID, actorUserID, organizationID, actorUserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
return nil
}
func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
var status string
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", organizations.ErrMembershipNotFound
}
return "", err
}
if status != "active" && status != "suspended" {
return "", errors.New("authsqlite: stored membership status is invalid")
}
return status, nil
}
func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
var targetIsOwner int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil {
@@ -464,16 +605,37 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o
if targetIsOwner == 0 {
return nil
}
var activeOwners int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) FROM gwf_access_bindings b JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active' WHERE b.organization_id=? AND b.subject_kind='user' AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL`, organizationID, ownerRole).Scan(&activeOwners); err != nil {
var otherActiveOwners int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
return err
}
if activeOwners <= 1 {
if otherActiveOwners == 0 {
return organizations.ErrLastOwner
}
return nil
}
func validMembershipStatusChange(input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) bool {
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
(input.Status == "active" || input.Status == "suspended") && input.ExpectedStatus != input.Status &&
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
audit.Action == "membership."+input.Status && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
}
func validMembershipRemoval(input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) bool {
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
audit.Action == "membership.remove" && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
}
func (store *Store) Invitations(ctx context.Context, organizationID string, limit int) ([]organizations.Invitation, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("authsqlite: invalid invitation query")
+96
View File
@@ -3,8 +3,10 @@
package authsqlite
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
@@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig
return tx.Commit()
}
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authrecovery.ErrGrantNotFound
}
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authrecovery.ErrGrantNotFound
}
return user, err
}
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
credential := completion.Credential
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
return errors.New("authsqlite: invalid passkey recovery completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, digest := range completion.RecoveryDigests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid recovery-code digest")
}
if _, exists := seen[digest]; exists {
return errors.New("authsqlite: duplicate recovery-code digest")
}
seen[digest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var userID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.ErrGrantNotFound
}
if err != nil {
return err
}
if userID != credential.UserID {
return errors.New("authsqlite: passkey recovery identity mismatch")
}
var active, pending int
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return auth.ErrInactiveUser
}
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
if err != nil {
return err
}
for existing.Next() {
var id []byte
if err = existing.Scan(&id); err != nil {
existing.Close()
return err
}
if bytes.Equal(id, credential.ID) {
existing.Close()
return errors.New("authsqlite: passkey credential already exists")
}
}
if err = existing.Close(); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
return err
}
for _, digest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery attempt")
+6 -1
View File
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
return store, nil
}
const SchemaVersion = 8
const SchemaVersion = 9
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
@@ -136,6 +136,8 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
@@ -234,6 +236,9 @@ func (store *Store) Migrate(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
+276 -3
View File
@@ -438,7 +438,7 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "organization.owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-last-owner"); !errors.Is(err, organizations.ErrLastOwner) {
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-last-owner"}); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last-owner suspension err=%v", err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
@@ -463,10 +463,10 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
if err != nil || len(teams) != 1 || teams[0].ID != team.ID {
t.Fatalf("member teams=%+v err=%v", teams, err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-suspend-owner"); err != nil {
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-suspend-owner"}); err != nil {
t.Fatal(err)
}
if err = organizationService.RemoveMembership(t.Context(), organization.ID, member.ID, member.ID, "request-last-member"); !errors.Is(err, organizations.ErrLastOwner) {
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-last-member"}); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("sole active owner removal err=%v", err)
}
if _, err = organizationService.SetOrganizationStatus(t.Context(), organizations.SetOrganizationStatus{ID: organization.ID, Status: "archived", ActorUserID: member.ID, ExpectedRevision: organization.Revision, RequestID: "request-archive"}); err != nil {
@@ -477,3 +477,276 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
}
}
func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"},
Permissions: map[string]string{"site.view": "View site"},
Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}},
}
accessService, err := access.New(store, policy, access.Options{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)
}
ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
members, err := organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") {
t.Fatalf("members=%+v err=%v", members, err)
}
direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil || len(direct) != 2 {
t.Fatalf("direct=%+v err=%v", direct, err)
}
type replacementResult struct {
binding access.Binding
err error
}
start := make(chan struct{})
results := make(chan replacementResult, 2)
for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} {
requestID := requestID
go func() {
<-start
binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}})
results <- replacementResult{binding: binding, err: replaceErr}
}()
}
close(start)
var memberOwner access.Binding
var successful, conflicted int
for range 2 {
result := <-results
switch {
case result.err == nil:
successful++
memberOwner = result.binding
case errors.Is(result.err, access.ErrRoleChangeConflict):
conflicted++
default:
t.Fatalf("concurrent replacement err=%v", result.err)
}
}
if successful != 1 || conflicted != 1 {
t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) {
t.Fatalf("stale replacement err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged replacement err=%v", err)
}
ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last-owner demotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil {
t.Fatal(err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil {
t.Fatal(err)
}
members, err = organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") {
t.Fatalf("suspended members=%+v err=%v", members, err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil {
t.Fatal("suspended member role was replaced")
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil {
t.Fatal(err)
}
duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now}
if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil {
t.Fatal(err)
}
replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now}
if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil {
t.Fatal("audit failure did not roll back role replacement")
}
direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil {
t.Fatal(err)
}
var memberRoles []string
for _, binding := range direct {
if binding.SubjectID == member.ID {
memberRoles = append(memberRoles, binding.ID+":"+binding.Role)
}
}
if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" {
t.Fatalf("rollback member roles=%v", memberRoles)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
}
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.owner", Email: "lifecycle-owner@example.test", DisplayName: "Lifecycle Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.member", Email: "lifecycle-member@example.test", DisplayName: "Lifecycle Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "optimistic-lifecycle", Name: "Optimistic Lifecycle", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
policy := access.Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"owner": {"telemetry.read"}, "viewer": {"telemetry.read"}}}
accessService, err := access.New(store, policy, access.Options{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: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "viewer", TeamIDs: []string{team.ID}, Lifetime: 24 * time.Hour})
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
for _, requestID := range []string{"request-suspend-one", "request-suspend-two"} {
requestID := requestID
go func() {
<-start
results <- organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: requestID})
}()
}
close(start)
var successful, conflicted int
for range 2 {
switch lifecycleErr := <-results; {
case lifecycleErr == nil:
successful++
case errors.Is(lifecycleErr, organizations.ErrRevisionConflict):
conflicted++
default:
t.Fatalf("concurrent membership suspension err=%v", lifecycleErr)
}
}
if successful != 1 || conflicted != 1 {
t.Fatalf("concurrent membership suspension success=%d conflict=%d", successful, conflicted)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action='membership.suspended' AND resource_id=?`, member.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || decision.Allowed {
t.Fatalf("suspended member decision=%+v err=%v", decision, err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-stale-remove"}); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale membership removal err=%v", err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-remove", 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 1)
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-reactivate"}); err != nil {
t.Fatal(err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-stale-reactivate"}); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale membership reactivation err=%v", err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-reactivate", 0)
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-remove-member"}); err != nil {
t.Fatal(err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NOT NULL`, member.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-remove-member", 1)
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || decision.Allowed {
t.Fatalf("removed member decision=%+v err=%v", decision, err)
}
}
func membershipPresent(values []organizations.Membership, userID, status string) bool {
for _, value := range values {
if value.UserID == userID && value.Status == status {
return true
}
}
return false
}
+71 -6
View File
@@ -12,8 +12,10 @@ import (
"errors"
"fmt"
"io"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -39,6 +41,11 @@ type Config struct {
RPID string
RPDisplayName string
Origin string
// AllowDevelopmentPort permits an explicit non-default HTTPS port only
// for localhost or a reserved .test relying-party ID. Production origins
// remain portless, while local applications can terminate trusted HTTPS
// without requiring a privileged listener.
AllowDevelopmentPort bool
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin); err != nil {
if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
@@ -218,6 +225,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
}
// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a
// short-lived recovery grant selected by the application. The grant itself is
// never persisted in ceremony state; only its digest is retained.
func (service *Service) BeginRecoveryRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
if len(binding) < 16 || len(binding) > 4096 {
return BeginResult{}, ErrOperationBinding
}
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
if user.RegistrationPending || user.Status != "active" {
return BeginResult{}, auth.ErrInactiveUser
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), false)
}
// BeginPasswordMigration starts registration for an already authenticated
// password-backed user. Completion atomically retires the password and revokes
// all sessions, including the session that authorized this ceremony.
@@ -262,7 +286,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false, false, nil)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil)
}
// FinishRegistrationForUser verifies an ordinary self-service enrollment only
// when the ceremony belongs to the authenticated user selected by the
// application. The ceremony is consumed on mismatch so a leaked token cannot
// be retried through another account session.
func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) {
expectedUserID = strings.TrimSpace(expectedUserID)
if expectedUserID == "" {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil)
}
// FinishAccountRegistration verifies an initial credential and delegates its
@@ -274,7 +310,21 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
}
// FinishRecoveryRegistration verifies a replacement passkey and delegates its
// persistence to commit so recovery-grant consumption, credential storage, and
// recovery-code replacement can share one transaction.
func (service *Service) FinishRecoveryRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error {
audit.Action = "auth.recovery.passkey"
audit.Summary = "A replacement passkey was enrolled during account recovery."
return commit(commitContext, credential, audit)
})
}
// FinishPasswordMigration verifies the new passkey and persists it together
@@ -287,11 +337,14 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
}
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
if err != nil {
return Credential{}, err
}
if expectedUserID != "" && ceremony.UserID != expectedUserID {
return Credential{}, ErrOperationBinding
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
}
@@ -679,12 +732,24 @@ func passwordMigrationBinding(userID string) [32]byte {
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
}
func validateOrigin(rpID, rawOrigin string) error {
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
port := origin.Port()
if port == "" {
if origin.Host != rpID {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test")
value, portErr := strconv.ParseUint(port, 10, 16)
if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
+45
View File
@@ -4,6 +4,7 @@ package authwebauthn_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
@@ -53,6 +54,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, "another-user", []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("cross-account registration completion err=%v", err)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, user.ID, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("mismatched completion did not consume ceremony: %v", err)
}
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
t.Fatalf("readiness without credentials err=%v", err)
@@ -141,6 +148,30 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
}
}
func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) {
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
binding := bytes.Repeat([]byte("restricted recovery grant "), 2)
begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding)
if err != nil {
t.Fatal(err)
}
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("tampered recovery binding err=%v", err)
}
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err)
}
if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("short recovery binding err=%v", err)
}
}
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
@@ -200,11 +231,25 @@ func TestConfigurationAndEntropyFailures(t *testing.T) {
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
for _, config := range []authwebauthn.Config{
{RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true},
{RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true},
} {
configured, configureErr := authwebauthn.New(store, authService, config)
if configureErr != nil || configured == nil {
t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: MPL-2.0
// Package bootstrap creates the first application owner and non-personal
// organization as one storage transaction. It is intended for a root-local
// operator command, not for public registration or a network administration
// endpoint.
package bootstrap
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
const defaultEnrollmentLifetime = 15 * time.Minute
var (
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
// Input is the reviewed, non-secret identity and organization metadata from a
// local operator command.
type Input struct {
Username string
Email string
DisplayName string
OrganizationSlug string
OrganizationName string
}
// Setup is the complete secret-free state a repository must commit atomically.
// Enrollment contains only a digest; the raw token remains in the Result.
type Setup struct {
User auth.User
Enrollment authwebauthn.EnrollmentToken
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
}
// Result contains the created public records and the one-time enrollment
// secret. Applications must deliver EnrollmentToken through a private channel
// and must never log it.
type Result struct {
User auth.User
Organization organizations.Organization
EnrollmentToken string
ExpiresAt time.Time
}
// Repository owns the single transaction spanning identity, enrollment,
// organization membership, owner access, and their audit events.
type Repository interface {
CreateInitialOwner(context.Context, Setup) error
}
type Options struct {
OwnerRole string
EnrollmentLifetime time.Duration
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
ownerRole string
enrollmentLifetime time.Duration
random io.Reader
now func() time.Time
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("bootstrap: repository is required")
}
if !rolePattern.MatchString(options.OwnerRole) {
return nil, errors.New("bootstrap: owner role is invalid")
}
if options.EnrollmentLifetime == 0 {
options.EnrollmentLifetime = defaultEnrollmentLifetime
}
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
}
// Start atomically creates one active passkey-only owner, one active
// non-personal organization, direct owner access, and a single-use enrollment
// token. It does not create a session or expose a network bootstrap surface.
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
input.Username = strings.TrimSpace(input.Username)
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
return Result{}, errors.New("bootstrap: invalid owner or organization")
}
values, err := service.randomValues(7)
if err != nil {
return Result{}, err
}
now := service.now().UTC()
userID, organizationID, bindingID := values[0], values[1], values[2]
rawToken := values[3]
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
setup := Setup{
User: user,
Enrollment: enrollment,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
}
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
return Result{}, err
}
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
}
func (service *Service) randomValues(count int) ([]string, error) {
values := make([]string, count)
for index := range values {
bytes := make([]byte, 24)
if _, err := io.ReadFull(service.random, bytes); err != nil {
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
}
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
}
return values, nil
}
func canonicalEmail(value string) bool {
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return false
}
address, err := mail.ParseAddress(value)
return err == nil && address.Name == "" && address.Address == value
}
func bounded(value string, maximum int) bool {
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MPL-2.0
package bootstrap
import (
"context"
"errors"
"testing"
"time"
)
type recordingRepository struct {
setup Setup
err error
}
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
repository.setup = setup
return repository.err
}
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
repository := new(recordingRepository)
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
t.Fatalf("result=%+v setup=%+v", result, setup)
}
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
}
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
}
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
t.Fatalf("expires=%v", result.ExpiresAt)
}
}
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
repository := new(recordingRepository)
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
for _, input := range []Input{
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
} {
if _, startErr := service.Start(t.Context(), input); startErr == nil {
t.Fatalf("unsafe input accepted: %+v", input)
}
}
if repository.setup.User.ID != "" {
t.Fatal("repository was called for rejected input")
}
}
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
repository := &recordingRepository{err: errors.New("commit failed")}
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err == nil || result.EnrollmentToken != "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+39
View File
@@ -34,3 +34,42 @@ application concern belongs in the shared module.
explicit operator command.
- Commerce remains a separately versioned nested module so payment-provider
policy and catalog evolution do not enlarge the authentication core.
- Self-service enrollment exposed an authorization seam: completing a valid
ceremony and checking its user only after persistence is too late.
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
the application-authenticated user before storing a credential.
- First-owner provisioning exposed another cross-package transaction boundary.
`bootstrap` now commits the passkey-only user, enrollment digest,
non-personal organization, membership, direct owner binding, and audits
together. Applications must seed their owner role first and must write the
returned raw token only to a newly created private file.
- Recovery-code consumption alone is not a complete recovery path. The
restricted grant must survive an interrupted authenticator prompt yet be
consumed in the same transaction that stores the verified replacement
passkey and replacement code digests. `authrecovery.BeginPasskey` and
`FinishPasskey` now provide that boundary without creating an authenticated
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
- A portless-only WebAuthn origin rule made an unprivileged local HTTPS
exercise impossible even though WebAuthn origins include ports. The passkey
service now permits an explicit development port only when applications opt
in and the RP ID is `localhost` or reserved `.test`; production origins keep
the original portless default.
- Gamertan's staff-access page exposed a dangerous composition gap between
individual grant/revoke calls. Foundations now owns one optimistic,
transactional direct-role replacement that preserves the final active
owner and appends its audit before commit. The application still owns route
authorization, role presentation, CSRF, and the exact fresh-passkey
operation binding.
- Extending that page to membership suspension, reactivation, and removal
exposed the same time-of-check gap in the older lifecycle methods. The new
optimistic extension serializes on the active administrator membership,
rechecks the exact state bound into the passkey assertion, applies team and
direct-binding consequences, and writes the audit in one transaction.
- Human-assisted recovery cannot safely be expressed as a root command behind
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
application performs the human review and fresh operation-bound passkey
ceremony, while the SQLite transaction rechecks an active direct owner,
invalidates every old account authenticator, stores only the grant digest,
and writes identity plus organization audits. Grant completion installs the
replacement password, passkey, and recovery-code set atomically and never
issues a session.
+13 -1
View File
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy |
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
The packages are ordinary Go imports. Pin the current preview and verify its
module checksum:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
go mod verify
```
@@ -61,6 +62,17 @@ quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
For the first application owner, prefer `bootstrap.Start`. After explicitly
seeding the application's access policy, it creates the active passkey-only
user, non-personal home organization, membership, direct owner binding,
enrollment digest, and audit events in one repository transaction. A missing
owner role or duplicate identity rolls back every row. The application-owned
root-local command writes the returned raw enrollment token once to an
exclusive mode-`0600` file and must never print or log it.
For applications that still require a temporary password bootstrap,
`auth.GenerateTemporaryPassword` remains available:
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
```
Only imported packages are compiled and linked. The packages nevertheless
+30
View File
@@ -22,6 +22,27 @@ team membership can be removed independently. Configure `OwnerRole` when
constructing the service before exposing membership-removal operations. The
SQLite adapter then refuses to suspend or remove the final active direct owner.
Fresh-authentication administration pages should use
`ChangeMembershipStatus` and `RemoveMembershipIfCurrent`, passing the exact
displayed state as `ExpectedStatus`. The SQLite adapter acquires its write lock
before checking that state, verifies the actor is still an active member of an
active organization, and commits the lifecycle effects and audit together.
Suspension removes team memberships; reactivation does not infer or restore
them. Removal also revokes current direct bindings. A repository without the
optimistic extension fails closed instead of falling back to a stale mutation.
For a reviewed access-administration page, use `organizations.Members` to list
bounded active and suspended memberships, and
`access.OrganizationUserBindings` to list only current direct,
organization-wide user roles. The latter intentionally excludes team grants
and project, environment, or service bindings. Replace a member's direct role
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
rejects stale state, writes the new binding and audit event atomically, and
will not demote the final active direct owner. The application must still
authorize the administrator and bind any required fresh passkey assertion to
the organization, target user, target role, and expected IDs.
`access.Service` evaluates a permission against a complete resource scope:
```go
@@ -43,6 +64,15 @@ grant organization-data access. If an operator must inspect tenant data during
an incident, use a reasoned break-glass grant. It expires within one hour and
creates an append-only audit event in the same transaction.
An application that offers owner-assisted account recovery must not infer that
authority from a broad administration page. Use the dedicated
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
assertion. The SQLite adapter requires a current active direct owner binding
and active target membership in the same transaction that invalidates the old
credentials and records the organization-visible recovery audit. Team,
break-glass, platform, and merely descriptive roles do not satisfy this owner
check.
The SQLite adapter namespaces all tables, enforces active organization and team
membership plus resource ancestry before accepting or evaluating a binding,
and keeps invitations and sessions as digests. Applications remain responsible
+53 -10
View File
@@ -9,6 +9,11 @@ authorization decisions, session cookie, HTML, and local recovery command.
## Fixed security policy
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
- Keep production origins portless. For local development only,
`AllowDevelopmentPort` permits one explicit non-default port when the RP ID
is exactly `localhost` or beneath the reserved `.test` top-level domain. The
configured origin, browser `Origin`, and WebAuthn verifier origin must still
match exactly.
- Reject cross-origin ceremonies.
- Require discoverable credentials and user verification.
- Request no attestation conveyance.
@@ -26,12 +31,17 @@ timestamp, UUID, or counter for the random challenge.
## Application flow
1. A local command calls `Bootstrap` or `Recover` and writes the returned
enrollment token once to a newly created mode-`0600` file.
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
`bootstrap.Start` and writes the returned enrollment token once to a newly
created mode-`0600` file. Use `bootstrap.Start` for the first application
owner so identity, organization membership, direct owner access, and audits
cannot be partially committed.
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
`navigator.credentials.create` with the returned `public_key` value.
3. The browser posts the credential and opaque ceremony token to a bounded JSON
endpoint; `FinishRegistration` verifies and stores the public credential.
endpoint; authenticated self-service flows use
`FinishRegistrationForUser` so the application session's user ID is checked
before any public credential is stored.
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
The successful result contains an ordinary opaque `auth` session token.
5. Sensitive operations call `BeginApproval` with a canonical application
@@ -51,13 +61,46 @@ JavaScript, or set sessions automatically.
## Recovery and credential lifecycle
Recovery is deliberately host-local and should never be reachable through an
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
prior enrollment tokens, appends a secret-free audit event, and returns one
15-minute token. It does not delete existing passkeys. After enrolling a
replacement, the operator reviews credential labels and removes lost keys with
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
remotely.
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
must never be reachable through an HTTP handler. It revokes all user sessions
and pending ceremonies, replaces prior enrollment tokens, appends a
secret-free audit event, and returns one 15-minute token. It does not delete
existing passkeys. After enrolling a replacement, the operator reviews
credential labels and removes lost keys with a fresh passkey-bound removal
ceremony. The final passkey cannot be removed remotely.
An account may separately expose self-service password-plus-recovery-code
recovery through `authrecovery`. `Begin` verifies the password, consumes one
printable code, revokes sessions, and returns a short-lived grant—not a normal
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
replacement passkey, replaces the entire recovery-code set, revokes any
sessions or ceremonies created during recovery, and returns the new plaintext
codes exactly once. It does not issue a session; return the user to normal
login after displaying and saving the new codes.
A failed storage commit leaves the restricted grant available for a fresh
ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
Applications must use generic failure responses and the same credential-attempt
rate limiting as login.
Owner-assisted recovery is a third, deliberately separate path. Configure
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
and bind that owner's fresh passkey assertion to the exact organization,
target user, request identifier, and bounded human-review reason before calling
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
owner and target membership, invalidates the target's password, passkeys,
recovery codes, sessions, and pending ceremonies, then stores only a digest of
the 15-minute grant with identity and organization-visible audits.
Deliver the returned grant exactly once in a URL fragment. A public recovery
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
keeping it out of request URLs, referrers, and access logs. Completion consumes
the grant atomically with one replacement password, passkey, recovery-code set,
and both audit trails. It issues no session. Losing the fragment after issuance
requires another reviewed owner or root-local recovery; old authenticators
must not become valid again as a fallback.
Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill.
+57 -1
View File
@@ -20,6 +20,7 @@ import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
if detected == "application/pdf" && validPDF(data) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
return result, nil
}
// validPDF performs a deliberately bounded structural check without trying to
// render or interpret document content. It rejects header-only spoofing and
// truncated uploads by requiring a supported header, terminal EOF marker, a
// numeric startxref offset, and either a traditional xref table with trailer
// or an xref-stream object at that offset.
func validPDF(data []byte) bool {
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
return false
}
headerEnd := bytes.IndexAny(data, "\r\n")
if headerEnd < 8 || headerEnd > 32 {
return false
}
header := string(bytes.TrimSpace(data[:headerEnd]))
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
return false
}
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
return false
}
eof := len(trimmed) - len("%%EOF")
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
if start < headerEnd {
return false
}
cursor := start + len("startxref")
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
cursor++
}
digits := cursor
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
cursor++
}
if cursor == digits {
return false
}
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
return false
}
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
return false
}
target := trimmed[int(offset):start]
if bytes.HasPrefix(target, []byte("xref")) {
return bytes.Contains(target, []byte("trailer"))
}
lineEnd := bytes.IndexByte(target, '\n')
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
return false
}
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
+21 -1
View File
@@ -5,6 +5,7 @@ package media
import (
"bytes"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
pdf := minimalPDF()
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
}
}
func TestPrepareRejectsMalformedPDF(t *testing.T) {
for _, source := range []string{
"%PDF-1.7\nsmall fixture",
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
} {
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("malformed PDF error=%v source=%q", err, source)
}
}
}
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err)
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
t.Fatalf("large err=%v", err)
}
}
func minimalPDF() []byte {
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
offset := len(prefix)
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
}
+4 -1
View File
@@ -5,6 +5,7 @@ package medialocal
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
+75
View File
@@ -21,6 +21,7 @@ import (
var (
ErrInvitationNotFound = errors.New("organizations: invitation not found")
ErrMembershipNotFound = errors.New("organizations: membership not found")
ErrMembershipLifecycleUnsupported = errors.New("organizations: optimistic membership lifecycle is unsupported")
ErrOrganizationNotFound = errors.New("organizations: organization not found")
ErrTeamNotFound = errors.New("organizations: team not found")
ErrRevisionConflict = errors.New("organizations: revision conflict")
@@ -107,10 +108,21 @@ type Repository interface {
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error)
TeamsForUser(context.Context, string, string) ([]Team, error)
}
// OptimisticMembershipRepository is implemented by repositories that can
// bind a membership lifecycle mutation to the exact state authorized by the
// caller. Services deliberately do not fall back to the older lifecycle
// methods: a stale fresh-authentication ceremony must fail instead of acting
// on a membership that changed while the ceremony was in progress.
type OptimisticMembershipRepository interface {
ChangeMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
RemoveMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
}
type Options struct {
Random io.Reader
Now func() time.Time
@@ -331,6 +343,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe
return service.repository.MembershipsForUser(ctx, userID)
}
// Members returns a bounded, stable list of active and suspended memberships
// for one organization. Authorization remains an application concern because
// the same storage primitive serves different organization policies.
func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("organizations: invalid member query")
}
return service.repository.OrganizationMemberships(ctx, organizationID, limit)
}
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid team query")
@@ -453,6 +475,34 @@ func (service *Service) SetMembershipStatus(ctx context.Context, organizationID,
return service.repository.SetMembershipStatus(ctx, organizationID, userID, status, service.ownerRole, audit)
}
// MembershipStatusChange describes an exact active-to-suspended or
// suspended-to-active transition. ExpectedStatus is part of the authorized
// operation and is checked again inside the repository transaction.
type MembershipStatusChange struct {
OrganizationID, UserID, ExpectedStatus, Status, ActorUserID, RequestID string
}
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus ||
!boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership status change")
}
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
return ErrMembershipLifecycleUnsupported
}
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status)
if err != nil {
return err
}
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
}
func (service *Service) RemoveMembership(ctx context.Context, organizationID, userID, actorUserID, requestID string) error {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
return errors.New("organizations: invalid membership removal")
@@ -467,6 +517,31 @@ func (service *Service) RemoveMembership(ctx context.Context, organizationID, us
return service.repository.RemoveMembership(ctx, organizationID, userID, service.ownerRole, audit)
}
// MembershipRemoval binds removal to the exact membership state observed by
// the caller before fresh authentication began.
type MembershipRemoval struct {
OrganizationID, UserID, ExpectedStatus, ActorUserID, RequestID string
}
func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership removal")
}
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
return ErrMembershipLifecycleUnsupported
}
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
if err != nil {
return err
}
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
func (service *Service) Invitations(ctx context.Context, organizationID string, limit int) ([]Invitation, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("organizations: invalid invitation query")
+17
View File
@@ -47,11 +47,25 @@ func TestInvitationFailsClosed(t *testing.T) {
}
}
func TestOptimisticMembershipLifecycleFailsClosedWithoutRepositorySupport(t *testing.T) {
service, err := New(&repositoryStub{}, Options{OwnerRole: "organization.owner"})
if err != nil {
t.Fatal(err)
}
if err = service.ChangeMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", Status: "suspended", ActorUserID: "user-87654321", RequestID: "request-suspend"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
t.Fatalf("status change err=%v", err)
}
if err = service.RemoveMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", ActorUserID: "user-87654321", RequestID: "request-remove"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
t.Fatalf("removal err=%v", err)
}
}
type repositoryStub struct {
organization Organization
invitation Invitation
invitationErr error
acceptedUser string
members []Membership
}
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
@@ -109,4 +123,7 @@ func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
return nil, nil
}
func (repository *repositoryStub) OrganizationMemberships(context.Context, string, int) ([]Membership, error) {
return repository.members, nil
}
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
+6
View File
@@ -30,6 +30,7 @@ auth/password.go
auth/password_test.go
authrecovery/recovery.go
authrecovery/recovery_test.go
authrecovery/assisted_test.go
auth/service_test.go
authhttp/authhttp.go
authhttp/authhttp_test.go
@@ -40,6 +41,9 @@ authsqlite/store_test.go
authsqlite/account.go
authsqlite/account_test.go
authsqlite/access.go
authsqlite/assisted_recovery.go
authsqlite/bootstrap.go
authsqlite/bootstrap_test.go
authsqlite/organizations.go
authsqlite/passkey.go
authsqlite/passkey_test.go
@@ -48,6 +52,8 @@ authwebauthn/fuzz_test.go
authwebauthn/service.go
authwebauthn/service_test.go
authwebauthn/types.go
bootstrap/bootstrap.go
bootstrap/bootstrap_test.go
media/media.go
media/media_test.go
medialocal/store.go