This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## v0.1.0-preview.11 — 2026-09-03
|
||||||
|
|
||||||
- Add expected-user completion for authenticated self-service passkey
|
- Add expected-user completion for authenticated self-service passkey
|
||||||
|
|||||||
@@ -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
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
packages you import.
|
||||||
|
|
||||||
> **Public preview:** `v0.1.0-preview.11`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.12`. APIs may change before a stable
|
||||||
> release. Linux is the maintained release platform.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -40,6 +40,7 @@ packages you import.
|
|||||||
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
|
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
|
||||||
| Atomic password-plus-passkey registration | [`account`](account) |
|
| Atomic password-plus-passkey registration | [`account`](account) |
|
||||||
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
||||||
|
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
|
||||||
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
|
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
|
||||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||||
@@ -56,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.11
|
go get gamertan.com/web@v0.1.0-preview.12
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,3 +38,8 @@ application concern belongs in the shared module.
|
|||||||
ceremony and checking its user only after persistence is too late.
|
ceremony and checking its user only after persistence is too late.
|
||||||
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
|
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
|
||||||
the application-authenticated user before storing a credential.
|
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.
|
||||||
|
|||||||
+13
-1
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
|
|||||||
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
The packages are ordinary Go imports. Pin the current preview and verify its
|
||||||
module checksum:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,6 +62,17 @@ quietly changing identity or policy.
|
|||||||
|
|
||||||
## Bootstrap an account without inventing a permanent password
|
## 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
|
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
|
||||||
entropy. An application can store that value in a newly created private file
|
entropy. An application can store that value in a newly created private file
|
||||||
and provision an account with `RequirePasswordChange: true`. The library does
|
and provision an account with `RequirePasswordChange: true`. The library does
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
+5
-2
@@ -26,8 +26,11 @@ timestamp, UUID, or counter for the random challenge.
|
|||||||
|
|
||||||
## Application flow
|
## Application flow
|
||||||
|
|
||||||
1. A local command calls `Bootstrap` or `Recover` and writes the returned
|
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
|
||||||
enrollment token once to a newly created mode-`0600` file.
|
`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
|
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
|
||||||
`navigator.credentials.create` with the returned `public_key` value.
|
`navigator.credentials.create` with the returned `public_key` value.
|
||||||
3. The browser posts the credential and opaque ceremony token to a bounded JSON
|
3. The browser posts the credential and opaque ceremony token to a bounded JSON
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ authsqlite/store_test.go
|
|||||||
authsqlite/account.go
|
authsqlite/account.go
|
||||||
authsqlite/account_test.go
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
|
authsqlite/bootstrap.go
|
||||||
|
authsqlite/bootstrap_test.go
|
||||||
authsqlite/organizations.go
|
authsqlite/organizations.go
|
||||||
authsqlite/passkey.go
|
authsqlite/passkey.go
|
||||||
authsqlite/passkey_test.go
|
authsqlite/passkey_test.go
|
||||||
@@ -48,6 +50,8 @@ authwebauthn/fuzz_test.go
|
|||||||
authwebauthn/service.go
|
authwebauthn/service.go
|
||||||
authwebauthn/service_test.go
|
authwebauthn/service_test.go
|
||||||
authwebauthn/types.go
|
authwebauthn/types.go
|
||||||
|
bootstrap/bootstrap.go
|
||||||
|
bootstrap/bootstrap_test.go
|
||||||
media/media.go
|
media/media.go
|
||||||
media/media_test.go
|
media/media_test.go
|
||||||
medialocal/store.go
|
medialocal/store.go
|
||||||
|
|||||||
Reference in New Issue
Block a user