Compare commits

..
Author SHA1 Message Date
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
gamertan 92ef63ba00 Add atomic account registration and local media
verify / verify (push) Successful in 3m35s
2026-09-03 11:51:53 -04:00
29 changed files with 2730 additions and 72 deletions
+49
View File
@@ -2,6 +2,55 @@
# Changelog
## 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,
password authentication, printable recovery codes, a personal organization,
direct owner access, and an optional initial passkey. Pending registrations
cannot authenticate, and abandoned drafts expire without reserving identity
fields indefinitely.
- Add password verification without session issuance plus operation-bound
WebAuthn completion hooks, allowing applications to require fresh passkeys
for sensitive actions without imposing passkeys on ordinary customer use.
- Add digest-only recovery-code persistence and short-lived, single-use
recovery grants that consume a code and revoke existing sessions atomically.
- Add bounded raster/PDF media preparation and a hardened content-addressed
local filesystem adapter with atomic writes, private modes, and symlink
rejection.
- Add explicit SQLite open-without-migration and schema-requirement APIs while
preserving the historical migrating `Open` behavior for existing adopters.
- Record application dogfood findings and the independent future commerce
module boundary.
## v0.1.0-preview.9 — 2026-09-03
- Add a documented root package and executable composition example so the
+12 -5
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.9`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -38,8 +38,12 @@ packages you import.
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
| Browser and HTTP security primitives | [`websec`](websec) |
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Passkey-only authentication | [`authwebauthn`](authwebauthn) |
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) |
@@ -53,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.9
go get gamertan.com/web@v0.1.0-preview.13
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.9
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
```
The version belongs to the `gamertan.com/web` module. See the
@@ -91,10 +95,13 @@ JSONL logging.
- [`auth`](auth) defines storage-neutral users, password credentials, opaque
sessions, platform permissions, and audit events.
- [`account`](account) composes the first password, printable recovery codes,
personal organization, and owner access as one registration transaction,
optionally including an initial passkey.
- [`authhttp`](authhttp) connects those sessions to secure browser cookies and
request context without owning login routes or pages.
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
passkey-only enrollment, fresh-operation approval, and bounded recovery.
enrollment, operation-bound fresh approval, and bounded recovery.
- [`organizations`](organizations) and [`access`](access) keep platform
operation separate from organization-data authority while supporting teams,
invitations, scoped roles, and audited temporary access.
+338
View File
@@ -0,0 +1,338 @@
// SPDX-License-Identifier: MPL-2.0
// Package account orchestrates atomic account registration. Email is the
// canonical sign-in identifier; username remains the stable public/profile
// identity. Applications may finish with password-only base access or include
// an initial passkey when their onboarding policy requires one.
package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
var (
ErrRegistrationNotFound = errors.New("account: registration not found")
ErrPasskeysUnavailable = errors.New("account: passkeys are unavailable")
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
)
type Registration struct {
Digest [32]byte
User auth.User
CreatedAt, ExpiresAt time.Time
}
type RegistrationCompletion struct {
Credential *authwebauthn.Credential
RecoveryDigests [][32]byte
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
type Repository interface {
CreateRegistration(context.Context, Registration, string, auth.AuditEvent) error
Registration(context.Context, [32]byte, time.Time) (Registration, error)
CompleteRegistration(context.Context, [32]byte, RegistrationCompletion) error
}
type Passkeys interface {
BeginAccountRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
FinishAccountRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
type Sessions interface {
IssueSession(context.Context, string, time.Duration) (string, auth.Principal, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
RegistrationTTL time.Duration
SessionLifetime time.Duration
RecoveryCodes int
OwnerRole string
}
type Service struct {
repository Repository
passkeys Passkeys
sessions Sessions
random io.Reader
now func() time.Time
draftTTL time.Duration
sessionTTL time.Duration
codeCount int
ownerRole string
}
func New(repository Repository, passkeys Passkeys, sessions Sessions, options Options) (*Service, error) {
if repository == nil || sessions == nil {
return nil, errors.New("account: repository and sessions are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.RegistrationTTL == 0 {
options.RegistrationTTL = 15 * time.Minute
}
if options.SessionLifetime == 0 {
options.SessionLifetime = 12 * time.Hour
}
if options.RecoveryCodes == 0 {
options.RecoveryCodes = authrecovery.DefaultCodeCount
}
if options.OwnerRole == "" {
options.OwnerRole = "owner"
}
if options.RegistrationTTL < 5*time.Minute || options.RegistrationTTL > time.Hour || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour || options.RecoveryCodes < 5 || options.RecoveryCodes > 20 || !roleName(options.OwnerRole) {
return nil, errors.New("account: invalid registration policy")
}
return &Service{repository: repository, passkeys: passkeys, sessions: sessions, random: options.Random, now: options.Now, draftTTL: options.RegistrationTTL, sessionTTL: options.SessionLifetime, codeCount: options.RecoveryCodes, ownerRole: options.OwnerRole}, nil
}
type StartInput struct {
Email, Username, DisplayName, Password string
}
type StartResult struct {
RegistrationToken string
User auth.User
ExpiresAt time.Time
}
// Start validates and stores a bounded pending registration. The returned
// secret is displayed only to the same browser flow and binds every following
// ceremony to this draft.
func (service *Service) Start(ctx context.Context, input StartInput) (StartResult, error) {
email, err := canonicalEmail(input.Email)
if err != nil {
return StartResult{}, err
}
username := strings.TrimSpace(input.Username)
displayName := strings.TrimSpace(input.DisplayName)
if !usernamePattern.MatchString(username) || displayName == "" || len(displayName) > 128 || strings.ContainsAny(displayName, "\x00\r\n") {
return StartResult{}, errors.New("account: invalid profile")
}
passwordHash, err := auth.HashPasswordWithRandom(input.Password, service.random)
if err != nil {
return StartResult{}, err
}
userID, err := service.token(18)
if err != nil {
return StartResult{}, err
}
rawToken, err := service.token(32)
if err != nil {
return StartResult{}, err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", RegistrationPending: true, CreatedAt: now, UpdatedAt: now}
registration := Registration{Digest: sha256.Sum256([]byte(rawToken)), User: user, CreatedAt: now, ExpiresAt: now.Add(service.draftTTL)}
audit, err := service.authAudit(user.ID, "auth.account.registration.start", "A public account registration was started.")
if err != nil {
return StartResult{}, err
}
if err = service.repository.CreateRegistration(ctx, registration, passwordHash, audit); err != nil {
return StartResult{}, err
}
return StartResult{RegistrationToken: rawToken, User: user, ExpiresAt: registration.ExpiresAt}, nil
}
func (service *Service) BeginPasskey(ctx context.Context, registrationToken, label string) (authwebauthn.BeginResult, error) {
if service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginAccountRegistration(ctx, registration.User.ID, label, []byte(registrationToken))
}
type FinishResult struct {
User auth.User
Organization organizations.Organization
RecoveryCodes []string
SessionToken string
Principal auth.Principal
PasskeyCredential authwebauthn.Credential
}
// FinishPassword activates a base account without requiring WebAuthn. The
// application can require an operation-bound passkey assertion later for
// sensitive permissions.
func (service *Service) FinishPassword(ctx context.Context, registrationToken string) (FinishResult, error) {
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
completion.AuthAudit, err = service.authAudit(registration.User.ID, "auth.account.registration.complete", "The password-authenticated account registration was completed.")
if err != nil {
return FinishResult{}, err
}
if err = service.repository.CompleteRegistration(ctx, registration.Digest, completion); err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, authwebauthn.Credential{})
}
// FinishWithPasskey completes the same atomic account transaction while also
// storing a verified initial passkey.
func (service *Service) FinishWithPasskey(ctx context.Context, registrationToken, ceremonyToken string, response []byte) (FinishResult, error) {
if service.passkeys == nil {
return FinishResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
credential, err := service.passkeys.FinishAccountRegistration(ctx, ceremonyToken, []byte(registrationToken), response, func(commitCtx context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
completion.Credential = &verified
completion.AuthAudit = passkeyAudit
return service.repository.CompleteRegistration(commitCtx, registration.Digest, completion)
})
if err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, credential)
}
func (service *Service) finishSession(ctx context.Context, registration Registration, completion RegistrationCompletion, codes []string, credential authwebauthn.Credential) (FinishResult, error) {
user := registration.User
user.RegistrationPending = false
user.UpdatedAt = completion.CompletedAt
result := FinishResult{User: user, Organization: completion.Organization, RecoveryCodes: codes, PasskeyCredential: credential}
sessionToken, principal, err := service.sessions.IssueSession(ctx, user.ID, service.sessionTTL)
if err != nil {
// Registration is already durable. Preserve the one-time recovery codes
// in the returned result so an application can display them while asking
// the user to sign in again.
return result, fmt.Errorf("account: registration completed but session issuance failed: %w", err)
}
result.SessionToken, result.Principal = sessionToken, principal
return result, nil
}
func (service *Service) completion(registration Registration, recoveryDigests [][32]byte) (RegistrationCompletion, error) {
organizationID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
bindingID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
slugBytes := make([]byte, 6)
if _, err = io.ReadFull(service.random, slugBytes); err != nil {
return RegistrationCompletion{}, fmt.Errorf("account: secure randomness unavailable: %w", err)
}
now := service.now().UTC()
organization := organizations.Organization{ID: organizationID, Slug: "personal-" + hex.EncodeToString(slugBytes), Name: registration.User.DisplayName + " — Personal", Status: "active", Personal: true, Revision: 1, CreatedAt: now, UpdatedAt: now}
membership := organizations.Membership{OrganizationID: organizationID, UserID: registration.User.ID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: registration.User.ID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: registration.User.ID, GrantedAt: now}
organizationAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
accessAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
return RegistrationCompletion{
RecoveryDigests: recoveryDigests,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
OrganizationAudit: organizations.AuditEvent{ID: organizationAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "organization.personal.create", ResourceType: "organization", ResourceID: organizationID, Summary: "Personal organization created during account registration.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: accessAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "access.owner.grant", ResourceType: "user", ResourceID: registration.User.ID, Summary: "Initial personal-organization owner access granted.", CreatedAt: now},
CompletedAt: now,
}, nil
}
func (service *Service) registration(ctx context.Context, raw string) (Registration, error) {
if len(raw) < 32 || len(raw) > 128 {
return Registration{}, ErrRegistrationNotFound
}
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
return Registration{}, ErrRegistrationNotFound
}
return service.repository.Registration(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
}
func (service *Service) authAudit(userID, action, summary string) (auth.AuditEvent, error) {
id, err := service.token(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: userID, Action: action, ResourceType: "user", ResourceID: userID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) token(size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return "", fmt.Errorf("account: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
parsed, err := mail.ParseAddress(value)
if err != nil || parsed.Address != value || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("account: a valid email address is required")
}
return value, nil
}
func roleName(value string) bool {
if len(value) < 2 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value[1:] {
if character < 'a' || character > 'z' && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' {
return false
}
}
return true
}
+35 -17
View File
@@ -31,8 +31,14 @@ type User struct {
ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
// RegistrationPending keeps a partially completed public registration
// ineligible for authentication until its credentials, personal scope, and
// recovery material have been committed atomically.
RegistrationPending bool
}
func (user User) Active() bool { return user.Status == "active" && !user.RegistrationPending }
type Principal struct {
User User
Roles []string
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
if !user.Active() {
return ErrInactiveUser
}
if currentPassword == newPassword {
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
}
if user.Status != "active" {
if !user.Active() {
return User{}, ErrInactiveUser
}
if VerifyPassword(currentHash, input.TemporaryPassword) {
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
return user, nil
}
// VerifyPassword verifies the password credential for an active account
// without creating a session. Applications use it as the first step of a
// bounded multi-factor ceremony and must not treat success as an authenticated
// browser session on its own.
func (service *Service) VerifyPassword(ctx context.Context, identifier, password string) (User, error) {
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return User{}, ErrInvalidCredentials
}
if !user.Active() {
return User{}, ErrInactiveUser
}
return user, nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")
}
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, ErrInvalidCredentials
}
user, err := service.VerifyPassword(ctx, identifier, password)
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return "", Principal{}, ErrInvalidCredentials
}
if user.Status != "active" {
return "", Principal{}, ErrInactiveUser
return "", Principal{}, err
}
return service.IssueSession(ctx, user.ID, lifetime)
}
@@ -282,7 +300,7 @@ func (service *Service) IssueSession(ctx context.Context, userID string, lifetim
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err
}
if principal.User.Status != "active" {
if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, ErrInactiveUser
}
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
if err != nil {
return Principal{}, fmt.Errorf("auth: load session: %w", err)
}
if principal.User.Status != "active" {
if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest)
return Principal{}, ErrInactiveUser
}
+41
View File
@@ -57,6 +57,31 @@ func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
}
}
func TestVerifyPasswordDoesNotIssueSession(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
repository := &credentialRepository{
user: User{ID: "valid-user-id", Username: "person", Email: "person@example.test", Status: "active"},
hash: hash,
}
service, err := New(repository, Options{})
if err != nil {
t.Fatal(err)
}
user, err := service.VerifyPassword(t.Context(), "person@example.test", "correct horse battery staple")
if err != nil || user.ID != repository.user.ID {
t.Fatalf("user=%+v err=%v", user, err)
}
if repository.sessionCreated {
t.Fatal("password verification issued a session")
}
if _, err = service.VerifyPassword(t.Context(), "person@example.test", "wrong password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong password err=%v", err)
}
}
type recordingRepository struct {
repositoryStub
deleted bool
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
deleted bool
}
type credentialRepository struct {
repositoryStub
user User
hash string
sessionCreated bool
}
func (repository *credentialRepository) CredentialByIdentifier(context.Context, string) (User, string, error) {
return repository.user, repository.hash, nil
}
func (repository *credentialRepository) CreateSession(context.Context, Session) error {
repository.sessionCreated = true
return nil
}
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
return repository.principal, Session{}, nil
}
+292
View File
@@ -0,0 +1,292 @@
// SPDX-License-Identifier: MPL-2.0
// Package authrecovery provides printable one-time recovery codes and bounded
// recovery grants for password-plus-passkey accounts.
package authrecovery
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
)
type Grant struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Repository interface {
ReplaceRecoveryCodes(context.Context, string, [][32]byte, time.Time, auth.AuditEvent) error
ConsumeRecoveryCodeAndCreateGrant(context.Context, string, [32]byte, Grant, auth.AuditEvent) error
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
}
// 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)
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
Passkeys Passkeys
}
type Service struct {
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
passkeys Passkeys
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
if repository == nil || passwords == nil {
return nil, errors.New("authrecovery: repository and password verifier are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.CodeCount == 0 {
options.CodeCount = DefaultCodeCount
}
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 {
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, passkeys: options.Passkeys}, nil
}
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
// once; only domain-separated digests are persisted.
func (service *Service) ReplaceCodes(ctx context.Context, userID, actorUserID string) ([]string, error) {
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return nil, err
}
now := service.now().UTC()
auditID, err := token(service.random, 18)
if err != nil {
return nil, err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: actorUserID, Action: "auth.recovery-codes.replace", ResourceType: "user", ResourceID: userID, Summary: "The account recovery-code set was replaced.", CreatedAt: now}
if err = service.repository.ReplaceRecoveryCodes(ctx, userID, digests, now, audit); err != nil {
return nil, err
}
return codes, nil
}
// Begin verifies the password, atomically consumes one code, revokes sessions,
// and returns a short-lived grant. Applications bind the grant to the passkey
// replacement ceremony and do not issue a normal session from it.
func (service *Service) Begin(ctx context.Context, identifier, password, code string) (auth.User, string, error) {
user, err := service.passwords.VerifyPassword(ctx, identifier, password)
if err != nil {
return auth.User{}, "", err
}
digest, err := DigestCode(code)
if err != nil {
return auth.User{}, "", auth.ErrInvalidCredentials
}
rawGrant, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := Grant{Digest: sha256.Sum256([]byte(rawGrant)), UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(service.grantTTL)}
auditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.begin", ResourceType: "user", ResourceID: user.ID, Summary: "A recovery code was consumed and existing sessions were revoked.", CreatedAt: now}
if err = service.repository.ConsumeRecoveryCodeAndCreateGrant(ctx, user.ID, digest, grant, audit); err != nil {
if errors.Is(err, ErrCodeNotFound) {
return auth.User{}, "", auth.ErrInvalidCredentials
}
return auth.User{}, "", err
}
return user, rawGrant, nil
}
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
digest, err := grantDigest(raw)
if err != nil {
return auth.User{}, err
}
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) {
if random == nil || count < 1 || count > 20 {
return nil, nil, errors.New("authrecovery: invalid code-set request")
}
codes := make([]string, 0, count)
digests := make([][32]byte, 0, count)
seen := make(map[[32]byte]struct{}, count)
for len(codes) < count {
value := make([]byte, 16)
if _, err := io.ReadFull(random, value); err != nil {
return nil, nil, fmt.Errorf("authrecovery: secure randomness unavailable: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value)
code := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20], encoded[20:26]}, "-")
digest, _ := DigestCode(code)
if _, duplicate := seen[digest]; duplicate {
continue
}
seen[digest] = struct{}{}
codes = append(codes, code)
digests = append(digests, digest)
}
return codes, digests, nil
}
func DigestCode(code string) ([32]byte, error) {
normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", ""))
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
if err != nil || len(decoded) != 16 {
return [32]byte{}, ErrCodeNotFound
}
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 {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: MPL-2.0
package authrecovery_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"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) {
now := time.Date(2026, 9, 3, 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)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.person", Email: "recover@example.test", DisplayName: "Recover Person", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
codes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
if err != nil || len(codes) != authrecovery.DefaultCodeCount {
t.Fatalf("codes=%d err=%v", len(codes), err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
loaded, grant, err := recovery.Begin(t.Context(), strings.ToUpper(user.Email), "correct horse battery staple", strings.ToLower(codes[0]))
if err != nil || loaded.ID != user.ID || grant == "" {
t.Fatalf("loaded=%+v grant=%q err=%v", loaded, grant, err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", codes[0]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("code replay err=%v", err)
}
loaded, err = recovery.TakeGrant(t.Context(), grant)
if err != nil || loaded.ID != user.ID {
t.Fatalf("grant user=%+v err=%v", loaded, err)
}
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
t.Fatalf("grant replay err=%v", err)
}
}
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) {
for index := range target {
reader.value++
target[index] = reader.value
}
return len(target), nil
}
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
)
func (store *Store) CreateRegistration(ctx context.Context, registration account.Registration, passwordHash string, audit auth.AuditEvent) error {
user := registration.User
if zeroDigest(registration.Digest) || !validPendingUser(user) || !registration.CreatedAt.Equal(user.CreatedAt) || !registration.ExpiresAt.After(registration.CreatedAt) || registration.ExpiresAt.Sub(registration.CreatedAt) > time.Hour || !text(passwordHash, 1024, false) || !validAuditEvent(audit) || audit.ActorUserID != user.ID || audit.ResourceID != user.ID {
return errors.New("authsqlite: invalid account registration")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// A bounded abandoned registration must not reserve its email or username
// forever. Deleting the pending user cascades every private draft artifact.
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_users WHERE registration_pending=1 AND id IN (SELECT user_id FROM gwf_account_registrations WHERE expires_at<=?)`, registration.CreatedAt.Unix()); err != nil {
return err
}
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(?,?,?,?,?,?,?,0,1,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_registrations(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, registration.Digest[:], user.ID, registration.CreatedAt.Unix(), registration.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) Registration(ctx context.Context, digest [32]byte, now time.Time) (account.Registration, error) {
if zeroDigest(digest) || now.IsZero() {
return account.Registration{}, account.ErrRegistrationNotFound
}
var registration account.Registration
var passwordChangeRequired, pending int
var created, updated, draftCreated, expires int64
err := 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,r.created_at,r.expires_at FROM gwf_account_registrations r JOIN gwf_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.expires_at>? AND u.registration_pending=1`, digest[:], now.Unix()).Scan(&registration.User.ID, &registration.User.Username, &registration.User.Email, &registration.User.DisplayName, &registration.User.Status, &passwordChangeRequired, &pending, &created, &updated, &draftCreated, &expires)
if errors.Is(err, sql.ErrNoRows) {
return account.Registration{}, account.ErrRegistrationNotFound
}
if err != nil {
return account.Registration{}, err
}
registration.Digest = digest
registration.User.PasswordChangeRequired = passwordChangeRequired == 1
registration.User.RegistrationPending = pending == 1
registration.User.CreatedAt = time.Unix(created, 0).UTC()
registration.User.UpdatedAt = time.Unix(updated, 0).UTC()
registration.CreatedAt = time.Unix(draftCreated, 0).UTC()
registration.ExpiresAt = time.Unix(expires, 0).UTC()
return registration, nil
}
func (store *Store) CompleteRegistration(ctx context.Context, digest [32]byte, completion account.RegistrationCompletion) error {
userID := completion.Membership.UserID
validOptionalCredential := completion.Credential == nil || validCredential(*completion.Credential, true) && completion.Credential.UserID == userID
if zeroDigest(digest) || !validOptionalCredential || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || !validOrganization(completion.Organization) || !completion.Organization.Personal || completion.Membership.OrganizationID != completion.Organization.ID || !opaqueID(userID) || completion.Membership.Status != "active" || completion.Membership.JoinedAt.IsZero() || !validOwnerBinding(completion.OwnerBinding, completion.Organization.ID, userID) || !validAuditEvent(completion.AuthAudit) || completion.AuthAudit.ActorUserID != userID || !validOrganizationAudit(completion.OrganizationAudit, completion.Organization.ID) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.OrganizationID != completion.Organization.ID || completion.CompletedAt.IsZero() {
return errors.New("authsqlite: invalid account registration completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, recoveryDigest := range completion.RecoveryDigests {
if zeroDigest(recoveryDigest) {
return errors.New("authsqlite: invalid recovery code digest")
}
if _, exists := seen[recoveryDigest]; exists {
return errors.New("authsqlite: duplicate recovery code digest")
}
seen[recoveryDigest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var registeredUserID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_account_registrations WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], completion.CompletedAt.Unix()).Scan(&registeredUserID)
if errors.Is(err, sql.ErrNoRows) {
return account.ErrRegistrationNotFound
}
if err != nil {
return err
}
if registeredUserID != userID {
return account.ErrRegistrationNotFound
}
var pending int
if err = tx.QueryRowContext(ctx, `SELECT registration_pending FROM gwf_users WHERE id=? AND status='active'`, userID).Scan(&pending); err != nil || pending != 1 {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return account.ErrRegistrationNotFound
}
if completion.Credential != nil {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, completion.Credential.ID, userID, completion.Credential.Label, []byte(completion.Credential.Data), completion.Credential.CreatedAt.Unix()); err != nil {
return err
}
}
for _, recoveryDigest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, recoveryDigest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
organization := completion.Organization
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,1,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, userID, 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(?,?,?,?)`, completion.Membership.OrganizationID, userID, completion.Membership.Status, completion.Membership.JoinedAt.Unix()); err != nil {
return err
}
binding := completion.OwnerBinding
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, userID, binding.Role, userID, 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: account owner role has not been seeded")
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET registration_pending=0,updated_at=? WHERE id=? AND registration_pending=1`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, completion.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validPendingUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && user.RegistrationPending && !user.PasswordChangeRequired && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validOwnerBinding(binding access.Binding, organizationID, userID string) bool {
return opaqueID(binding.ID) && binding.SubjectKind == access.User && binding.SubjectID == userID && safeName(binding.Role) && binding.Scope.OrganizationID == organizationID && binding.Scope.ProjectID == "" && binding.Scope.EnvironmentID == "" && binding.Scope.ServiceID == "" && binding.GrantedBy == userID && !binding.GrantedAt.IsZero()
}
var _ account.Repository = (*Store)(nil)
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestAccountRegistrationCommitsEveryRequiredArtifact(t *testing.T) {
store, authService, accountService, passkeys := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "PERSON@example.test", Username: "person.one", DisplayName: "Person One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if started.User.Email != "person@example.test" || !started.User.RegistrationPending {
t.Fatalf("pending user=%+v", started.User)
}
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("pending password verification err=%v", err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`))
if err != nil {
t.Fatal(err)
}
if finished.User.RegistrationPending || finished.User.ID != started.User.ID || len(finished.RecoveryCodes) != 10 || finished.SessionToken == "" || !finished.Organization.Personal {
t.Fatalf("finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
if passkeys.userID != started.User.ID || passkeys.binding != started.RegistrationToken {
t.Fatalf("passkey binding user=%q binding=%q", passkeys.userID, passkeys.binding)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND role_name='owner'`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 0)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); err != nil {
t.Fatalf("completed password verification: %v", err)
}
}
func TestPasswordAccountCanFinishWithoutPasskey(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "reader@example.test", Username: "reader.one", DisplayName: "Reader One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishPassword(t.Context(), started.RegistrationToken)
if err != nil {
t.Fatal(err)
}
if finished.SessionToken == "" || len(finished.RecoveryCodes) != 10 || len(finished.PasskeyCredential.ID) != 0 {
t.Fatalf("password finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
if _, err = authService.VerifyPassword(t.Context(), "reader@example.test", "correct horse battery staple"); err != nil {
t.Fatalf("password account not active: %v", err)
}
}
func TestAccountRegistrationRollsBackWhenOwnerPolicyIsMissing(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, false)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "rollback@example.test", Username: "rollback.one", DisplayName: "Rollback One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
if _, err = accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)); err == nil {
t.Fatal("completion unexpectedly succeeded without seeded owner role")
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 1)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("rolled-back account became usable: %v", err)
}
}
func accountFixture(t *testing.T, seedOwner bool) (*Store, *auth.Service, *account.Service, *accountPasskeys) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "identity.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if seedOwner {
err = store.SeedAccessPolicy(t.Context(), access.Policy{
Roles: map[string]string{"owner": "Personal organization owner"},
Permissions: map[string]string{"account.view": "View the account"},
Grants: map[string][]string{"owner": {"account.view"}},
})
if err != nil {
t.Fatal(err)
}
}
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
passkeys := &accountPasskeys{now: time.Now().UTC()}
accountService, err := account.New(store, passkeys, authService, account.Options{})
if err != nil {
t.Fatal(err)
}
return store, authService, accountService, passkeys
}
type accountPasskeys struct {
userID, binding string
now time.Time
}
func (passkeys *accountPasskeys) BeginAccountRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
passkeys.userID = userID
passkeys.binding = string(binding)
return authwebauthn.BeginResult{CeremonyToken: "ceremony-token", PublicKey: []byte(`{}`), ExpiresAt: passkeys.now.Add(5 * time.Minute)}, nil
}
func (passkeys *accountPasskeys) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
if ceremonyToken != "ceremony-token" || string(binding) != passkeys.binding {
return authwebauthn.Credential{}, authwebauthn.ErrOperationBinding
}
credential := authwebauthn.Credential{ID: []byte("fixture-credential-id"), UserID: passkeys.userID, Label: "Primary passkey", Data: []byte(`{"id":"fixture-credential-id"}`), CreatedAt: passkeys.now}
audit := auth.AuditEvent{ID: "passkey-audit-id", ActorUserID: passkeys.userID, Action: "auth.account.passkey", ResourceType: "passkey", ResourceID: "fixture-credential-id", Summary: "The initial account passkey was enrolled.", CreatedAt: passkeys.now}
if err := commit(ctx, credential, audit); err != nil {
return authwebauthn.Credential{}, err
}
return credential, nil
}
func assertCount(t *testing.T, store *Store, query, id string, want int) {
t.Helper()
var got int
if err := store.db.QueryRow(query, id).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("count for %q = %d, want %d", query, got, want)
}
}
+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
}
+10 -9
View File
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
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,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
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, user.RegistrationPending, 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(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
@@ -45,7 +45,7 @@ func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, err
if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
}
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
if !text(identifier, 320, false) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
}
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
if !boundedCredentialID(credentialID) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
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_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
@@ -291,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte,
if err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
@@ -310,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
return auth.User{}, err
}
defer tx.Rollback()
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
if err != nil {
return auth.User{}, err
}
@@ -342,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var created, updated int64
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil {
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrUserNotFound
}
return auth.User{}, err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, nil
}
func validPasskeyUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery-code set")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
return err
}
for _, digest := range digests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid recovery-code digest")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
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")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:])
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil || changed != 1 {
return authrecovery.ErrCodeNotFound
}
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_recovery_grants WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authrecovery.ErrGrantNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authrecovery.ErrGrantNotFound
} else if err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
+66 -9
View File
@@ -24,6 +24,17 @@ import (
type Store struct{ db *sql.DB }
func Open(path string) (*Store, error) {
return OpenWithOptions(path, OpenOptions{Migrate: true})
}
type OpenOptions struct {
// Migrate preserves the historical Open behavior when true. Applications
// with operator-controlled releases set it false and call Migrate only from
// their explicit migration command.
Migrate bool
}
func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, err
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
store := &Store{db: db}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err = db.PingContext(ctx); err == nil {
if err = db.PingContext(ctx); err == nil && options.Migrate {
err = store.Migrate(ctx)
}
if err != nil {
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
return store, nil
}
const SchemaVersion = 8
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='gamertan_web_migrations'`).Scan(&exists); err != nil || exists == 0 {
return 0, err
}
var version sql.NullInt64
if err := store.db.QueryRowContext(ctx, `SELECT MAX(version) FROM gamertan_web_migrations`).Scan(&version); err != nil {
return 0, err
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}
func (store *Store) RequireCurrentSchema(ctx context.Context) error {
version, err := store.CurrentSchema(ctx)
if err != nil {
return err
}
if version != SchemaVersion {
return fmt.Errorf("authsqlite: schema version %d; run migration for version %d", version, SchemaVersion)
}
return nil
}
func (store *Store) Close() error { return store.db.Close() }
func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) }
@@ -77,7 +116,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback()
statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), registration_pending INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -94,6 +133,11 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
`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_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)`,
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
@@ -132,6 +176,7 @@ func (store *Store) Migrate(ctx context.Context) error {
for _, migration := range []struct {
table, column, definition string
}{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
@@ -180,6 +225,15 @@ func (store *Store) Migrate(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(6,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(7,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
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
}
return tx.Commit()
}
@@ -212,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
_, 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, user.PasswordChangeRequired, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil {
return err
}
@@ -228,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
err := 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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
@@ -238,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
@@ -248,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
err := 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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
@@ -258,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
@@ -346,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal
var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64
var passwordChangeRequired int
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
var passwordChangeRequired, registrationPending int
err := 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,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
}
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
principal.User.RegistrationPending = registrationPending == 1
if err != nil {
return auth.Principal{}, auth.Session{}, err
}
+18
View File
@@ -17,6 +17,24 @@ import (
"gamertan.com/web/organizations"
)
func TestOpenCanRequireExplicitMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "explicit.db")
store, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err = store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("unmigrated database reported current")
}
if err = store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
+95 -16
View File
@@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{})
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
@@ -198,7 +198,41 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{})
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
// BeginAccountRegistration starts the initial passkey ceremony for a pending
// account. Binding must identify the surrounding single-use registration
// draft; only its digest is retained in ceremony state.
func (service *Service) BeginAccountRegistration(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), 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
@@ -216,15 +250,15 @@ func (service *Service) BeginPasswordMigration(ctx context.Context, userID, labe
if !exists {
return BeginResult{}, ErrPasswordNotAvailable
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID))
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID), false)
}
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte) (BeginResult, error) {
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte, allowPending bool) (BeginResult, error) {
label, err := credentialLabel(label)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, user)
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return BeginResult{}, err
}
@@ -245,7 +279,45 @@ 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)
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
// persistence to commit so user activation, personal organization creation,
// owner binding, recovery-code storage, and the passkey can share one
// transaction. A failed commit consumes the WebAuthn ceremony and leaves the
// bounded account draft eligible for a fresh ceremony.
func (service *Service) FinishAccountRegistration(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, 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
@@ -255,18 +327,21 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
if err != nil {
return Credential{}, err
}
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true)
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 bool) (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
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword)
if expectedUserID != "" && ceremony.UserID != expectedUserID {
return Credential{}, ErrOperationBinding
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
}
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) {
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
if ceremony.BindingDigest != expectedBinding {
return Credential{}, ErrOperationBinding
}
@@ -277,7 +352,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil {
return Credential{}, err
}
adapter, err := service.user(ctx, user)
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return Credential{}, err
}
@@ -312,6 +387,10 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
}
if retirePassword {
err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit)
} else if commit != nil {
audit.Action = "auth.account.passkey"
audit.Summary = "The initial account passkey was enrolled."
err = commit(ctx, record, audit)
} else {
err = service.repository.SaveCredential(ctx, record, audit)
}
@@ -358,7 +437,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r
if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound
}
loaded, lookupErr = service.user(ctx, account)
loaded, lookupErr = service.user(ctx, account, false)
return loaded, lookupErr
}, session, parsed)
if err != nil || loaded == nil || user == nil {
@@ -385,7 +464,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, account)
adapter, err := service.user(ctx, account, false)
if err != nil {
return BeginResult{}, err
}
@@ -415,7 +494,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
if err != nil {
return Approval{}, err
}
adapter, err := service.user(ctx, account)
adapter, err := service.user(ctx, account, false)
if err != nil {
return Approval{}, err
}
@@ -552,8 +631,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
return ceremony, nil
}
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
if account.Status != "active" {
func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
if account.Status != "active" || account.RegistrationPending && !allowPending {
return nil, auth.ErrInactiveUser
}
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
+31
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")
+11 -4
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MPL-2.0
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
// credential lifecycle, and recovery tokens while delegating protocol parsing
// and signature verification to a pinned WebAuthn implementation.
// Package authwebauthn provides storage-neutral WebAuthn ceremonies for
// passkey login, enrollment, and operation-bound step-up. It owns relying-party
// policy, bounded single-use ceremony state, credential lifecycle, and recovery
// tokens while delegating protocol parsing and signature verification to a
// pinned WebAuthn implementation.
package authwebauthn
import (
@@ -92,6 +93,12 @@ type Approval struct {
ApprovedAt time.Time
}
// RegistrationCommit lets a higher-level account workflow commit a verified
// initial credential together with the rest of the account state. The
// callback receives only public-key credential material and a secret-free
// audit event.
type RegistrationCommit func(context.Context, Credential, auth.AuditEvent) error
// Repository persists passkey-specific state. Implementations must consume
// enrollment tokens and ceremonies atomically and must perform recovery and
// credential removal invariants in transactions.
+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)
}
}
+51
View File
@@ -0,0 +1,51 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Web Foundations dogfood notes
This living note records concrete pressure discovered while Gamertan services
adopt Web Foundations. It is implementation evidence, not a promise that every
application concern belongs in the shared module.
## Gamertan accounts and commerce
- The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier and keeps username as a stable public
identity. Until a mail package exists, the application must not describe an
address as verified merely because it was entered during registration.
- Password authentication is sufficient for an ordinary customer base
session. Privileged application actions use an exact operation binding with
`authwebauthn.BeginApproval` and `FinishApproval`; that is safer than a broad
long-lived "elevated" session. A user without a passkey can use ordinary
features but must enroll one before performing protected work.
- `auth.Service.VerifyPassword` remains available for flows that truly require
password plus passkey before session issuance.
- Public registration exposed a cross-package transaction boundary. The
`account` package now keeps an unusable bounded registration draft and makes
recovery-code digests, personal organization, membership, owner binding,
activation, audits, and an optional initial passkey one repository commit.
A failed WebAuthn ceremony can be restarted, or an ordinary password account
can finish without it, without persisting a partly privileged account.
- Media belongs behind a storage-neutral interface with a hardened local
adapter. Content workflow, references, and authorization remain application
policy.
- Historical `authsqlite.Open` still migrates for compatibility. Applications
with reviewed deployment gates use `OpenWithOptions` with migration disabled,
require the current schema at startup, and invoke `Migrate` only from an
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.
+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.9
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
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
+7 -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.9
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
```
Only imported packages are compiled and linked. The packages nevertheless
@@ -52,6 +52,12 @@ Do not split merely to make an architecture diagram look modular. Package
interfaces provide source-level modularity today; modules are introduced only
for an independent dependency and release lifecycle.
The `media` package and `medialocal` adapter deliberately remain in the root
module: they use only the standard library, and applications can adopt the core
interface without importing the local adapter. Commerce is different. Its
provider SDK and independently evolving catalog/payment contract justify a
future nested `gamertan.com/web/commerce` module after application dogfood.
## Session boundaries
Authenticated sessions currently belong to three deliberate packages:
+31 -10
View File
@@ -26,12 +26,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 +56,29 @@ 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.
Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill.
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
// Package media defines bounded media preparation and storage-neutral blob
// interfaces. Applications retain authorization, references, lifecycle, and
// presentation policy.
package media
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"unicode/utf8"
)
const (
KindImage = "image"
KindAttachment = "attachment"
)
var (
ErrInvalidMedia = errors.New("media: invalid media")
ErrTooLarge = errors.New("media: upload exceeds its size limit")
ErrNotFound = errors.New("media: object not found")
)
type Limits struct {
MaxBytes int64
MaxWidth int
MaxHeight int
MaxPixels int64
}
func (limits Limits) withDefaults() Limits {
if limits.MaxBytes == 0 {
limits.MaxBytes = 10 << 20
}
if limits.MaxWidth == 0 {
limits.MaxWidth = 8192
}
if limits.MaxHeight == 0 {
limits.MaxHeight = 8192
}
if limits.MaxPixels == 0 {
limits.MaxPixels = 40_000_000
}
return limits
}
func (limits Limits) validate() error {
if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 {
return errors.New("media: invalid limits")
}
return nil
}
// Prepared is a sanitized, bounded object ready for durable storage. Raster
// images are decoded and re-encoded so source metadata and unparsed trailing
// bytes are not retained. PDFs are attachments and are never inline media.
type Prepared struct {
Digest [32]byte
Data []byte
MediaType string
Kind string
OriginalName string
Width int
Height int
}
func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) }
type Object struct {
Key string
Size int64
MediaType string
CreatedAt time.Time
}
type Store interface {
Put(context.Context, Prepared) (Object, error)
Open(context.Context, string) (io.ReadCloser, Object, error)
Delete(context.Context, string) error
}
// Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a
// PDF attachment. Animated images are deliberately flattened to the decoded
// first frame. The returned byte slice is owned by the caller.
func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) {
if reader == nil {
return Prepared{}, ErrInvalidMedia
}
limits = limits.withDefaults()
if err := limits.validate(); err != nil {
return Prepared{}, err
}
name, err := boundedName(originalName)
if err != nil {
return Prepared{}, err
}
data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1))
if err != nil {
return Prepared{}, fmt.Errorf("media: read upload: %w", err)
}
if int64(len(data)) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
if len(data) == 0 {
return Prepared{}, ErrInvalidMedia
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
imageValue, format, err := image.Decode(bytes.NewReader(data))
if err != nil || format != "jpeg" && format != "png" && format != "gif" {
return Prepared{}, ErrInvalidMedia
}
bounds := imageValue.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) {
return Prepared{}, ErrTooLarge
}
var output bytes.Buffer
mediaType := "image/png"
if format == "jpeg" {
mediaType = "image/jpeg"
err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90})
} else {
err = png.Encode(&output, imageValue)
}
if err != nil {
return Prepared{}, fmt.Errorf("media: sanitize image: %w", err)
}
if int64(output.Len()) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "application/pdf":
return ".pdf"
default:
values, _ := mime.ExtensionsByType(mediaType)
if len(values) > 0 {
return values[0]
}
return ""
}
}
func ValidKey(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
}
func boundedName(value string) (string, error) {
value = strings.TrimSpace(filepath.Base(value))
if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") {
return "", ErrInvalidMedia
}
return value, nil
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
package media
import (
"bytes"
"errors"
"image"
"image/color"
"image/jpeg"
"strings"
"testing"
)
func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
var source bytes.Buffer
value := image.NewRGBA(image.Rect(0, 0, 3, 2))
value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255})
if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
source.WriteString("secret trailing metadata")
prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" {
t.Fatalf("prepared=%+v", prepared)
}
if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) {
t.Fatal("image source data was not sanitized")
}
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
t.Fatalf("prepared=%+v", prepared)
}
}
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)
}
if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) {
t.Fatalf("large err=%v", err)
}
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MPL-2.0
// Package medialocal stores prepared media in a private content-addressed
// filesystem tree.
package medialocal
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gamertan.com/web/media"
)
type Store struct {
root string
now func() time.Time
}
type Options struct {
Now func() time.Time
}
func Open(root string, options Options) (*Store, error) {
absolute, err := filepath.Abs(root)
if err != nil || filepath.Clean(absolute) != absolute {
return nil, errors.New("medialocal: root must be a clean absolute path")
}
if err = secureDirectory(absolute, true); err != nil {
return nil, err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return nil, fmt.Errorf("medialocal: resolve root: %w", err)
}
if options.Now == nil {
options.Now = time.Now
}
return &Store{root: resolved, now: options.Now}, nil
}
func (store *Store) Put(ctx context.Context, prepared media.Prepared) (media.Object, error) {
if err := ctx.Err(); err != nil {
return media.Object{}, err
}
if len(prepared.Data) == 0 || !media.ValidKey(prepared.Key()) || sha256.Sum256(prepared.Data) != prepared.Digest {
return media.Object{}, media.ErrInvalidMedia
}
shard, target := store.objectPath(prepared.Key())
if err := secureDirectory(shard, true); err != nil {
return media.Object{}, err
}
if object, ok, err := inspect(target, prepared.MediaType); err != nil {
return media.Object{}, err
} else if ok {
if object.Size != int64(len(prepared.Data)) {
return media.Object{}, errors.New("medialocal: existing digest has an unexpected size")
}
return object, nil
}
temporary, err := os.CreateTemp(shard, ".upload-*")
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: create temporary object: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err = temporary.Chmod(0o640); err == nil {
_, err = temporary.Write(prepared.Data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: write object: %w", err)
}
if err = os.Link(temporaryName, target); err != nil {
if errors.Is(err, os.ErrExist) {
object, ok, inspectErr := inspect(target, prepared.MediaType)
if inspectErr != nil {
return media.Object{}, inspectErr
}
if ok && object.Size == int64(len(prepared.Data)) {
return object, nil
}
}
return media.Object{}, fmt.Errorf("medialocal: commit object: %w", err)
}
return media.Object{Key: prepared.Key(), Size: int64(len(prepared.Data)), MediaType: prepared.MediaType, CreatedAt: store.now().UTC()}, nil
}
func (store *Store) Open(ctx context.Context, key string) (io.ReadCloser, media.Object, error) {
if err := ctx.Err(); err != nil {
return nil, media.Object{}, err
}
if !media.ValidKey(key) {
return nil, media.Object{}, media.ErrNotFound
}
shard, target := store.objectPath(key)
if err := secureDirectory(shard, false); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
return nil, media.Object{}, err
}
before, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 {
return nil, media.Object{}, errors.New("medialocal: object is not a regular file")
}
file, err := os.Open(target)
if err != nil {
return nil, media.Object{}, err
}
after, err := file.Stat()
if err != nil || !os.SameFile(before, after) {
file.Close()
return nil, media.Object{}, errors.New("medialocal: object changed while opening")
}
return file, media.Object{Key: key, Size: after.Size(), CreatedAt: after.ModTime().UTC()}, nil
}
func (store *Store) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
if !media.ValidKey(key) {
return media.ErrNotFound
}
_, target := store.objectPath(key)
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("medialocal: refusing to delete a non-regular object")
}
if err = os.Remove(target); errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
return err
}
func (store *Store) objectPath(key string) (string, string) {
shard := filepath.Join(store.root, key[:2])
return shard, filepath.Join(shard, key)
}
func secureDirectory(path string, create bool) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) && create {
if err = os.MkdirAll(path, 0o750); err != nil {
return fmt.Errorf("medialocal: create directory: %w", err)
}
info, err = os.Lstat(path)
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("medialocal: storage directory must not be a symlink")
}
return nil
}
func inspect(path, mediaType string) (media.Object, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return media.Object{}, false, nil
}
if err != nil {
return media.Object{}, false, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return media.Object{}, false, errors.New("medialocal: existing object is not a regular file")
}
return media.Object{Key: filepath.Base(path), Size: info.Size(), MediaType: mediaType, CreatedAt: info.ModTime().UTC()}, true, nil
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package medialocal
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"gamertan.com/web/media"
)
func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC)
store, err := Open(filepath.Join(t.TempDir(), "media"), Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
first, err := store.Put(t.Context(), prepared)
if err != nil {
t.Fatal(err)
}
second, err := store.Put(t.Context(), prepared)
if err != nil || second.Key != first.Key || second.Size != first.Size {
t.Fatalf("second=%+v err=%v", second, err)
}
reader, object, err := store.Open(t.Context(), first.Key)
if err != nil {
t.Fatal(err)
}
data, readErr := io.ReadAll(reader)
closeErr := reader.Close()
if readErr != nil || closeErr != nil || !bytes.Equal(data, prepared.Data) || object.Size != int64(len(data)) {
t.Fatalf("round trip object=%+v read=%v close=%v", object, readErr, closeErr)
}
if err = store.Delete(t.Context(), first.Key); err != nil {
t.Fatal(err)
}
if _, _, err = store.Open(t.Context(), first.Key); !errors.Is(err, media.ErrNotFound) {
t.Fatalf("missing err=%v", err)
}
}
func TestOpenRejectsSymlinkRoot(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
if err := os.Mkdir(target, 0o750); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := Open(link, Options{}); err == nil {
t.Fatal("symlink root accepted")
}
}
+15
View File
@@ -17,6 +17,7 @@ SECURITY.md
THIRD_PARTY_NOTICES.md
abuse/abuse.go
abuse/abuse_test.go
account/account.go
access/access.go
access/access_test.go
analytics/analytics.go
@@ -27,6 +28,8 @@ auth/auth.go
auth/context.go
auth/password.go
auth/password_test.go
authrecovery/recovery.go
authrecovery/recovery_test.go
auth/service_test.go
authhttp/authhttp.go
authhttp/authhttp_test.go
@@ -34,18 +37,30 @@ authhttp/passkey.go
authhttp/passkey_test.go
authsqlite/store.go
authsqlite/store_test.go
authsqlite/account.go
authsqlite/account_test.go
authsqlite/access.go
authsqlite/bootstrap.go
authsqlite/bootstrap_test.go
authsqlite/organizations.go
authsqlite/passkey.go
authsqlite/passkey_test.go
authsqlite/recovery.go
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
medialocal/store_test.go
internal/webauthnvendored/
docs/ADOPTION.md
docs/ARCHITECTURE.md
docs/DEPENDENCIES.md
docs/DOGFOOD.md
docs/GETTING_STARTED.md
docs/MODULES.md
docs/ORGANIZATIONS.md