Add atomic account registration and local media
verify / verify (push) Successful in 3m35s

This commit is contained in:
2026-09-03 11:51:53 -04:00
parent 337b56ec1b
commit 92ef63ba00
23 changed files with 1816 additions and 62 deletions
+49 -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,24 @@ 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)
}
// BeginPasswordMigration starts registration for an already authenticated
@@ -216,15 +233,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 +262,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [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)
}
// FinishPasswordMigration verifies the new passkey and persists it together
@@ -255,18 +284,18 @@ 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 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)
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 +306,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 +341,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 +391,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 +418,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 +448,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 +585,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)
+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.