Bind passkey enrollment to authenticated user
verify / verify (push) Successful in 3m33s

This commit is contained in:
2026-09-03 12:40:20 -04:00
parent 92ef63ba00
commit 277cffed8c
8 changed files with 43 additions and 9 deletions
+7
View File
@@ -2,6 +2,13 @@
# Changelog
## 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,
+3 -3
View File
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.10`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.11`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
@@ -56,14 +56,14 @@ owns—and, just as importantly, what remains application policy.
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.10
go get gamertan.com/web@v0.1.0-preview.11
go mod verify
```
An application may name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
```
The version belongs to the `gamertan.com/web` module. See the
+18 -3
View File
@@ -262,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, false, nil)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil)
}
// FinishRegistrationForUser verifies an ordinary self-service enrollment only
// when the ceremony belongs to the authenticated user selected by the
// application. The ceremony is consumed on mismatch so a leaked token cannot
// be retried through another account session.
func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) {
expectedUserID = strings.TrimSpace(expectedUserID)
if expectedUserID == "" {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil)
}
// FinishAccountRegistration verifies an initial credential and delegates its
@@ -274,7 +286,7 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
}
// FinishPasswordMigration verifies the new passkey and persists it together
@@ -287,11 +299,14 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
}
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
if err != nil {
return Credential{}, err
}
if expectedUserID != "" && ceremony.UserID != expectedUserID {
return Credential{}, ErrOperationBinding
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
}
+6
View File
@@ -53,6 +53,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)
+4
View File
@@ -34,3 +34,7 @@ application concern belongs in the shared module.
explicit operator command.
- Commerce remains a separately versioned nested module so payment-provider
policy and catalog evolution do not enlarge the authentication core.
- Self-service enrollment exposed an authorization seam: completing a valid
ceremony and checking its user only after persistence is too late.
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
the application-authenticated user before storing a credential.
+1 -1
View File
@@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
go mod verify
```
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go get gamertan.com/web/requestmeta@v0.1.0-preview.11
```
Only imported packages are compiled and linked. The packages nevertheless
+3 -1
View File
@@ -31,7 +31,9 @@ timestamp, UUID, or counter for the random challenge.
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