Compare commits

...
Author SHA1 Message Date
gamertan 494b72fa3b Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
2026-09-11 04:12:17 -04:00
gamertan ebcbbf06f5 Document and verify optional CMS associations
verify / verify (push) Successful in 4m50s
2026-09-10 18:00:25 -04:00
gamertan fcb200453a Close the shared CMS package delivery checkpoint
verify / verify (push) Successful in 4m41s
2026-09-10 12:44:31 -04:00
gamertan d991ad4bdb Record CMS package verification for preview 27
verify / verify (push) Successful in 4m39s
2026-09-10 12:39:30 -04:00
gamertan 57d74bf601 Add revision-aware CMS taxonomies and relationships 2026-09-10 12:37:21 -04:00
gamertan a16283efd7 Add session-bound personal profile editing
verify / verify (push) Successful in 4m35s
2026-09-05 02:37:38 -04:00
gamertan 7c68a3499a Add bounded administrative identity directories
verify / verify (push) Successful in 4m31s
2026-09-05 01:19:57 -04:00
gamertan d476179148 Guard customer-owned profile and membership transactions
verify / verify (push) Successful in 4m25s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:33:24 -04:00
gamertan f142ac23a9 Invalidate old invitations when organization membership changes
verify / verify (push) Successful in 4m15s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:12:40 -04:00
gamertan c0986168bc Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:10:08 -04:00
gamertan ed0cc8ceff Add atomic owned organization creation
verify / verify (push) Successful in 3m48s
2026-09-04 23:19:05 -04:00
gamertan b1710e08b8 Verify passkey algorithms from COSE keys
verify / verify (push) Successful in 3m38s
2026-09-04 12:19:15 -04:00
gamertan 3fe1547a5b Protect owner invitation authority
verify / verify (push) Successful in 3m40s
2026-09-04 00:30:29 -04:00
gamertan d54d6a4ad1 Protect owner administration authority
verify / verify (push) Successful in 3m42s
2026-09-04 00:20:28 -04:00
gamertan 6f0b597943 Add owner-assisted account recovery
verify / verify (push) Successful in 3m39s
2026-09-03 23:56:42 -04:00
gamertan 59827bf641 Add optimistic membership lifecycle
verify / verify (push) Successful in 3m38s
2026-09-03 23:22:19 -04:00
gamertan fe6bd94c9a Add atomic organization role administration
verify / verify (push) Successful in 3m39s
2026-09-03 22:51:53 -04:00
gamertan 17bd9453e2 Allow explicit local WebAuthn ports
verify / verify (push) Successful in 3m35s
2026-09-03 22:30:23 -04:00
gamertan d8b09c8ae5 Reject malformed PDF media uploads
verify / verify (push) Successful in 3m38s
2026-09-03 13:47:56 -04:00
gamertan 95d50f0888 Complete passkey recovery transaction
verify / verify (push) Successful in 3m37s
2026-09-03 13:09:22 -04:00
gamertan 1f54c75501 Add atomic initial owner bootstrap
verify / verify (push) Successful in 3m33s
2026-09-03 12:50:16 -04:00
gamertan 277cffed8c Bind passkey enrollment to authenticated user
verify / verify (push) Successful in 3m33s
2026-09-03 12:40:20 -04:00
gamertan 92ef63ba00 Add atomic account registration and local media
verify / verify (push) Successful in 3m35s
2026-09-03 11:51:53 -04:00
gamertan 337b56ec1b docs: publish Web Foundations module landing
verify / verify (push) Successful in 3m37s
2026-09-03 10:05:17 -04:00
gamertan 7c8f0d708e requestlog: preserve audited connection upgrades
verify / verify (push) Successful in 3m26s
2026-08-28 13:38:35 -04:00
gamertan a769d1ea7b auth: publish self-hosted administration foundations
verify / verify (push) Successful in 3m32s
2026-08-27 10:37:38 -04:00
gamertan a39e8f893c requestlog: publish collector-readable evidence boundary
verify / verify (push) Successful in 3m28s
Publish the reviewed Gamertan Web Foundations v0.1.0-preview.6 snapshot with a narrow mode-0640 collector boundary, private mode-0600 default, explicit setgid ownership guidance, and native macOS-safe release verification.

Exported from reviewed private source 120d660fa432761f85316ca3dde990e2dd142f19 after trusted Gitea CI run 681 and the complete native Mac verification suite.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-24 17:06:47 -04:00
gamertan 5563306368 Document Web Foundations Preview 5
verify / verify (push) Successful in 3m55s
2026-08-21 17:45:19 -04:00
gamertan bfe6cfd29e auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s
2026-08-21 17:33:00 -04:00
gamertan fb6bbd0dad auth: publish audited password recovery
verify / verify (push) Successful in 3m7s
Publish the reviewed Gamertan Web Foundations v0.1.0-preview.4 snapshot with local-only administrative reset, atomic Argon2id credential replacement, mandatory rotation, all-session revocation, secret-free audit evidence, rollback coverage, and exact application-boundary guidance.

Exported from reviewed private source 403e5f6ef4d0cac683aaa76ed922236571d259a9 after trusted CI run 317 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 09:31:08 -04:00
gamertan 5905fe6fb2 auth: publish one-time bootstrap rotation
verify / verify (push) Successful in 3m14s
Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation.

Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 00:08:01 -04:00
gamertan 920e68f57f release: publish Web Foundations Preview 2 snapshot
verify / verify (push) Successful in 3m2s
Sanitized allowlisted snapshot of private source 0acd276fb3423405daf7fff26dedc92b8281e2bd. Adds organization, team, invitation, resource hierarchy, scoped access, and audited break-glass foundations while preserving Preview 1.

AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release preparation.
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-17 00:26:22 -04:00
gamertan 206d09e4cd docs: publish the Web Foundations application onramp
verify / verify (push) Successful in 2m58s
Sanitized snapshot of private source 144ca0a9544042b0477ae732c1356cf0b9d62b3f. Add package selection, adoption workflow, and optional Sandwich Hime integration guidance.

AI-Assistance: OpenAI Codex assisted documentation, verification, and publication.
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 21:10:18 -04:00
311 changed files with 60070 additions and 98 deletions
+1
View File
@@ -4,3 +4,4 @@
*.go text eol=lf
*.md text eol=lf
*.sql text eol=lf
third_party/go-webauthn/** -text linguist-vendored
+1
View File
@@ -33,6 +33,7 @@ jobs:
run: |
go test ./requestmeta -run '^$' -fuzz '^FuzzForwardedChain$' -fuzztime 30s
go test ./analytics -run '^$' -fuzz '^FuzzJSONL$' -fuzztime 30s
go test ./authwebauthn -run '^$' -fuzz '^FuzzPasskeyResponseParsers$' -fuzztime 30s
- name: Verify reproducible starter build
run: |
mkdir -p "$RUNNER_TEMP/build-a" "$RUNNER_TEMP/build-b"
+1 -1
View File
@@ -2,7 +2,7 @@
name: verify
on:
push:
branches: [main, 'codex/**']
branches: [main, 'codex/**', 'gamertan/**']
workflow_dispatch:
permissions:
contents: read
+10
View File
@@ -0,0 +1,10 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# AI assistance
Codex has materially assisted with implementation, tests, documentation, and
integration work in this project, including the organization and access-control
extensions. Assistance is disclosed here rather than repeated in every commit
subject. Repository tests, review, and release evidence—not the use of a
particular tool—determine readiness. Automated checks do not imply that every
line has received independent human review.
+348
View File
@@ -2,6 +2,354 @@
# Changelog
## v0.1.0-preview.28 — 2026-09-11
- Add bounded single-recipient SMTP with verified TLS, a fixed sender and
secret-free diagnostics, plus an explicit encrypted SQLite transactional outbox.
Cover cancellation, retry/lease recovery, idempotency and payload expiry locally.
- Add optional verified-mailbox, both-mailbox address-change and password-reset
protocols with a separate explicit SQLite migration, atomic audits/mail intent,
session/credential/replay checks and preserved passkeys, ownership and history.
Consumer HTTP/UI integration is locally verified; deployment and real delivery
proof remain separate consumer checkpoints.
- Document and test optional CMS classification and relationships, including
unclassified publication and clearing associations with preserved history.
No runtime behavior or schema change.
- Keep the existing passkey algorithm regression tests in the public export.
## v0.1.0-preview.27 — 2026-09-10
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
stable terms, immutable revision associations and bidirectional editorial
links. Applications retain typed content, templates, permissions and commerce.
- Join caller-owned SQLite transactions so content, publication pointers and
application audits commit together. Schema installation is explicit and
independent of authentication schema 11, which remains unchanged.
- Preserve term URL aliases and historical associations on rename/retirement.
Query only published snapshots, with namespace isolation and bounded keyset
pagination. The application still checks each target's current availability.
- Include executable integration guidance and regressions for conflicts,
rollback, draft isolation, reverse discovery, aliases and retirement. The
consumer exercised native editing and publication behind trusted local TLS.
## v0.1.0-preview.26 — 2026-09-05
- Add optional self-profile readers and revision-checked username/display-name
writes. Recheck the active session, account and expected revision atomically
with a secret-free audit; preserve immutable user identity and ownership.
- Username edits revoke other sessions but preserve the acting session. A
password-confirmed write can require the exact verified credential hash,
rejecting a concurrent password reset. Applications own reauthentication,
operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits.
- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows
begin at revision 1; startup with migrations disabled rejects older schemas.
Do not run older writers against schema 11 as a database rollback strategy.
- Email changes are deliberately absent; pending-address verification and mail
delivery are separate work. Test invalid/restricted sessions, collisions,
concurrent/stale edits, audit rollback, restart and schema-10 migration.
## v0.1.0-preview.25 — 2026-09-05
- Add optional, credential-free user and organization directory readers for
application-authorized instance administration. They include inactive/pending
users and personal/archived organizations independently of membership.
- Bound literal searches and stable-ID pagination to at most 200 returned
records. Queries do not load passwords, sessions, recovery material, invitations
or role grants; they grant no authority. Applications must authorize each read.
- Cover pagination, renamed records, literal SQL/wildcard input, Unicode text,
invalid bounds and cancellation. Schema 10 and existing repository contracts
remain unchanged; source exports include the new optional interfaces/readers.
## v0.1.0-preview.24 — 2026-09-05
- Add explicit owner-managed profile and optimistic membership operations.
Current direct ownership is checked inside the SQLite write transaction even
when the target is an ordinary member. Active account/membership, non-personal
organization, last-owner, optimistic state, and atomic audit requirements remain
intact. Existing delegated-administrator APIs retain their behavior.
- Require `OwnerManagedRepository` support without a preflight-only fallback.
No schema migration is added; schema 10 remains current.
- Test revoked, narrowed, suspended, removed and incomplete actor authority,
archived/personal organizations, stale and concurrent submissions, and rollback
of profile, membership, team, role and invitation effects after audit failure.
## v0.1.0-preview.23 — 2026-09-05
- Add atomic direct organization role sets with optimistic binding IDs, current
direct-owner authorization, last-owner protection, and a single audit. Roles
may be combined without changing narrower or team grants.
- Add bounded multiple-role invitations and opt-in owner-managed invitation
policy. Persist the required grantor authority and recheck it at acceptance,
together with active, fully registered users and recipient email. Suspended
members cannot reactivate themselves by accepting an older invitation.
- Invitations enroll new members rather than adding permissions to existing
members. Membership removal revokes pending invitations for that recipient
in the same transaction, preventing an older offer from restoring access.
- Add SQLite schema 10 for invitation role sets and stored owner authority.
Legacy single-role data remains readable after explicit migration; older
schema-9 applications are not approved writers of the migrated database.
Custom repositories must implement the role-set extensions before exposing
these operations; there is no non-atomic fallback.
- Include the owned-organization implementation and tests in the public-source
export, and compile the exported tree to catch incomplete source distributions.
- Cover competing changes and invitation acceptance, failure rollback, stale
owners, unsupported adapters, and migration of legacy invitations.
## v0.1.0-preview.22 — 2026-09-04
- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing
user's organization, initial membership, direct configured owner role, and
correlated organization/access audits.
- Require an active, fully registered owner and a pre-seeded role inside the
SQLite transaction. Missing storage support fails without a non-atomic fallback.
- Preserve the older membership-only creation API and schema version 9. Customer
and merchant permissions remain application-owned, with no commerce dependency.
- Exercise failure at every write stage, concurrent duplicate creation, scoped
access, restart recovery, last-owner protection, and mismatched authority/audits.
## v0.1.0-preview.21 — 2026-09-04
- Derive the registered credential algorithm from the verified COSE public key
embedded in authenticator data instead of the optional browser
`publicKeyAlgorithm` convenience member.
- Preserve the ES256-only policy while accepting standards-compliant response
serializers that omit redundant response conveniences, including the
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
- Add regression coverage for an ES256 credential whose convenience algorithm
is absent, plus malformed and non-ES256 credential rejection.
## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or
revoking an invitation that grants the configured owner role now requires
the actor to remain an active direct owner after the SQLite write lock is
acquired.
- Preserve application-owned permission policy for ordinary invitations while
preventing a broad access-management role, stale ceremony, or alternate
repository call from creating or cancelling owner access.
- Pass the configured owner role explicitly through invitation repository
mutations so non-SQLite adapters cannot silently omit the invariant.
## v0.1.0-preview.19 — 2026-09-04
- Require a current active direct owner for every direct-role transition to or
from the configured owner role. The SQLite adapter rechecks that authority
after acquiring its write lock, preventing a role manager from promoting
itself or changing an owner through a stale application authorization.
- Apply the same transactional owner-authority boundary to membership
suspension, reactivation, and removal, including the legacy lifecycle
methods. Non-owner administrators may still manage non-owner members while
last-owner protection remains a separate invariant.
- Expose stable owner-authority errors so applications can distinguish an
authorization drift conflict from malformed input or storage failure.
## v0.1.0-preview.18 — 2026-09-04
- Add owner-assisted account recovery for a documented human-review path when
normal password, passkey, and recovery-code authentication is unavailable.
Issuance requires an active direct organization owner and returns a bounded,
single-use, 15-minute secret while persisting and auditing only its digest.
- Invalidate the recovered member's existing password, passkeys, recovery
codes, sessions, ceremonies, and older recovery grants when the reviewed
enrollment is issued. Completion atomically installs one replacement
password, passkey, and recovery-code set without issuing a normal session.
- Keep identity and organization-visible recovery audits in the same SQLite
transactions as their credential changes, and document the application
boundary for fresh passkey authorization, secret-fragment delivery, and
human evidence review.
## v0.1.0-preview.17 — 2026-09-04
- Add optimistic organization-membership suspension, reactivation, and
removal for fresh-authentication administration flows. The exact displayed
membership state is rechecked after acquiring the SQLite write lock, so a
concurrent or stale ceremony fails without changing access or writing an
audit event.
- Keep membership lifecycle consequences transactional: suspension removes
team membership, removal also revokes direct bindings, reactivation does not
silently restore former teams, and every successful change appends its
organization-visible audit before commit.
- Strengthen last-owner protection to require another active direct owner
whose platform account is also active. Existing storage adapters retain the
legacy interface; security-sensitive applications fail closed unless their
repository implements the optimistic lifecycle extension.
## v0.1.0-preview.16 — 2026-09-03
- Add bounded organization-member and direct user-role listings for
application-owned access administration pages. Direct listings deliberately
exclude team and narrower resource grants rather than flattening distinct
authority into one apparent role.
- Add atomic direct-role replacement with exact expected-binding checks,
transactional access audit, active-member validation, and final active
direct-owner protection. SQLite serializes competing replacements so stale
administration fails with a stable conflict instead of partially applying.
- Record the Gamertan administration dogfood boundary: applications authorize
the route and fresh passkey assertion, while Foundations owns the reusable
storage transaction and invariants.
## v0.1.0-preview.15 — 2026-09-03
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin
port for `localhost` and reserved `.test` relying-party IDs. The configured
origin remains exact, production origins remain portless by default, and
malformed, default, non-canonical, zero, or out-of-range ports fail closed.
- Record the Gamertan local-Caddy dogfood pressure that required this explicit
development boundary without weakening cross-origin ceremony rejection.
## v0.1.0-preview.14 — 2026-09-03
- Reject header-only, truncated, and structurally invalid PDF uploads in the
bounded media preparer. Accepted attachments now require a supported PDF
version, terminal EOF marker, numeric in-range `startxref`, and either a
traditional xref/trailer or xref-stream object at the declared offset.
- Keep PDF handling storage-neutral and non-rendering: applications still own
authorization, reference tracking, attachment disposition, and lifecycle.
## v0.1.0-preview.13 — 2026-09-03
- Complete the password-plus-recovery-code flow with a short-lived restricted
grant bound into a replacement-passkey ceremony. Completion atomically
consumes the grant, stores the verified passkey, replaces every recovery
code, revokes any intervening sessions and ceremonies, and records both
audits without issuing a normal session.
- Keep failed completion retryable until grant expiry: a duplicate credential
or other transaction failure rolls back grant consumption and recovery-code
replacement, while a mismatched WebAuthn binding consumes only the affected
ceremony.
## v0.1.0-preview.12 — 2026-09-03
- Add a root-local bootstrap transaction that creates the first passkey-only
application owner, non-personal organization, active membership, direct
owner binding, one-time enrollment digest, and secret-free audit records
atomically.
- Fail closed and roll back the entire bootstrap when the application has not
seeded the configured owner role. The raw enrollment token is returned only
after commit and never enters repository state or audit records.
## v0.1.0-preview.11 — 2026-09-03
- Add expected-user completion for authenticated self-service passkey
enrollment. A mismatched ceremony is consumed and fails before credential
persistence, closing an authorization seam found while dogfooding Gamertan's
account security page.
## v0.1.0-preview.10 — 2026-09-03
- Add atomic public-account registration with required canonical email,
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
module landing page presents its purpose, package-selection guidance,
security model, and `net/http` integration rather than only a directory
index.
- Add the repository's default MPL-2.0 licence at the conventional root path
so Go package tooling can identify the library licence while preserving the
existing file-level exceptions for starters and operational machinery.
- Rework the public README around progressive adoption, explicit design
promises, package selection, assurance gates, and canonical project links.
## v0.1.0-preview.8 — 2026-08-28
- Preserve `http.Hijacker` through the request-evidence middleware so audited,
authenticated WebSocket and other HTTP upgrade handlers can operate without
bypassing request logging. Successful upgrades are recorded as HTTP 101;
upgraded-protocol bytes remain outside HTTP body-byte accounting.
- Add revisioned active/archived lifecycles for organizations and teams,
invitation listing and revocation, membership suspension/removal, team-member
removal, and transactional organization-visible audit events.
- Make archived organizations and teams ineffective during authorization and
preserve the final active direct owner during membership changes.
- Allow invitations to carry one bounded direct role and reviewed team
memberships, applied atomically with single-use acceptance.
- Add an atomic password-to-passkey migration ceremony that stores the first
passkey, retires the password credential, revokes all sessions, and records
the migration audit event in one transaction.
## v0.1.0-preview.6 — 2026-08-24
- Add an explicit mode-`0640` JSONL option for applications that authorize one
narrowly scoped collector group, while keeping private mode `0600` as the
default and rejecting permissive modes.
- Document the setgid-directory ownership boundary for Observatory-style
collection without granting the collector broader application access.
- Make vendored dependency and public-snapshot verification portable across
the maintained Linux gate and native macOS development environments.
- Keep Previews 1–5 immutable; applications select Preview 6 explicitly when
adopting collector-readable request evidence.
## v0.1.0-preview.5 — 2026-08-21
- Add storage-neutral passkey registration, discoverable login, and
operation-bound fresh assertions without adding self-registration, password
fallback, TOTP, email recovery, or application-owned routes.
- Require exact HTTPS relying-party origins, user verification, discoverable
credentials, no attestation conveyance, and an initial ES256-only algorithm
policy.
- Add transactional SQLite credential, ceremony, enrollment, recovery, and
last-credential protections with atomic single-use consumption.
- Add a neutral session-issuance boundary for independently verified
credentials while retaining existing password behavior.
- Pin WebAuthn protocol verification to `github.com/go-webauthn/webauthn`
`v0.17.1` and record its source identity, module checksums, licence, and
transitive security boundary.
- Add self-service passkey enrollment and removal primitives with fresh
assertion, session revocation, and last-credential protection.
- Keep Previews 1–4 immutable; applications select Preview 5 explicitly when
adopting the passkey boundary.
## v0.1.0-preview.4 — 2026-08-18
- Add an explicit local-administrator password recovery operation without
adding a public recovery endpoint or network protocol.
- Atomically install a one-time Argon2id credential, restore mandatory password
rotation, revoke every session, and append a secret-free audit event.
- Prove transaction rollback when the audit event cannot commit and document
private mode-`0600` delivery as application-owned policy.
- Keep Previews 1–3 immutable; applications select Preview 4 explicitly when
adopting administrative recovery.
## v0.1.0-preview.3 — 2026-08-18
- Add cryptographically generated temporary credentials and an explicit
password-change-required account state.
- Replace credentials, clear the requirement, and revoke all existing sessions
in one repository transaction after verifying the current password.
- Migrate existing SQLite users with the new requirement disabled; applications
continue to own first-login routing, private credential delivery, and audit
policy.
- Keep Preview 1 and Preview 2 immutable; applications select Preview 3
explicitly when adopting forced bootstrap rotation.
## v0.1.0-preview.2 — 2026-08-17
- Add storage-neutral organizations, teams, projects, environments, services,
single-use invitations, and independently scoped access roles.
- Separate platform-level authentication roles from organization data access.
- Add expiring break-glass grants with transactional organization-visible audit
events and a no-CGO SQLite implementation.
- Keep `v0.1.0-preview.1` immutable; applications adopt these additive packages
by explicitly selecting Preview 2.
## v0.1.0-preview.1 — 2026-08-16
- Establish independent request metadata, logging, browser security, abuse,
+375
View File
@@ -0,0 +1,375 @@
SPDX-License-Identifier: MPL-2.0
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+4
View File
@@ -14,3 +14,7 @@ fails closed on missing or misplaced identifiers.
Full texts are in `LICENSES/`. Combining these MPL-covered packages with an
application does not change the licence of the application's own files; changes
to covered files remain subject to the MPL. This summary is not legal advice.
The root [`LICENSE`](LICENSE) contains the default MPL-2.0 text for package
indexers and repository tooling. More specific file-level SPDX identifiers in
the paths above remain authoritative.
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2025 github.com/go-webauthn/webauthn authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+126 -44
View File
@@ -2,71 +2,153 @@
# Gamertan Web Foundations
> Status: `v0.1.0-preview.1` public preview. APIs may change before a stable
> release; Linux is the maintained release platform.
[![Go Reference](https://pkg.go.dev/badge/gamertan.com/web.svg)](https://pkg.go.dev/gamertan.com/web)
[![Verify](https://gitea.speelman.ca/gamertan/web/actions/workflows/verify.yml/badge.svg?branch=main)](https://gitea.speelman.ca/gamertan/web/actions?workflow=verify.yml)
Small, composable Go packages for the unglamorous boundaries of a careful web
application: request identity, structured request logs, browser security,
passwords and sessions, permissions, SQLite persistence, and private analytics.
**Security-conscious building blocks for ordinary `net/http` applications.**
This is a toolkit, not an application framework. Your application keeps its
router, HTTP policy, HTML, authorization decisions, cache behavior, and
deployment. Each package works with `net/http` and can be adopted independently.
Web Foundations provides small, composable Go packages for the unglamorous
boundaries of a careful web application: request identity, structured request
evidence, browser security, authentication, passkeys, permissions,
organizations, SQLite persistence, abuse controls, and private analytics.
The first preview targets modest Linux servers, local files, SQLite, and normal
Go binaries. It requires no Redis, message broker, hosted identity provider,
telemetry service, or JavaScript framework.
It is a toolkit, not an application framework. Your application keeps its
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.27`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
| Design promise | What it means in an application |
| --- | --- |
| `net/http` native | Keep the standard router or any compatible router; there is no framework lifecycle. |
| Explicit security boundaries | Trusted proxies, sensitive log fields, browser origins, and scoped authority are configured deliberately. |
| Bounded and fail-closed | Untrusted inputs are size-limited, and security-critical configuration or storage failures do not quietly weaken policy. |
| Storage-neutral core | Interfaces separate identity and access policy from the optional no-CGO SQLite adapter. |
| Self-hosted by default | No Redis, message broker, hosted identity provider, telemetry service, or JavaScript framework is required. |
## Start with one boundary
| Application need | Begin with |
| --- | --- |
| Request IDs and trustworthy client addresses | [`requestmeta`](requestmeta) |
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
| Browser and HTTP security primitives | [`websec`](websec) |
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Optional mailbox verification, address changes and password reset | [`authmail`](authmail) + [`authsqlite`](authsqlite); explicit mail migration |
| Bounded transactional SMTP and encrypted queued delivery | [`mail`](mail) + [`mailsqlite`](mailsqlite) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) |
| Disposable request-log summaries | [`analytics`](analytics) |
The [getting-started guide](docs/GETTING_STARTED.md) explains what each package
owns—and, just as importantly, what remains application policy.
## Install
Pin the preview in an application module, then import only the packages that
application needs:
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.1
go get gamertan.com/web@v0.1.0-preview.27
go mod verify
```
Canonical source, issues, security policy, and release notes live on
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin.
An application may name the first package it intends to adopt:
Linux is the required and supported release platform. WSL may be used as a
Linux development environment. Native Windows is not a release gate or support
promise; downstream users may evaluate the ordinary Go packages elsewhere
without turning that portability into a maintained compatibility claim.
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.27
```
## Packages
The version belongs to the `gamertan.com/web` module. See the
[module-boundary policy](docs/MODULES.md) before selecting a first slice.
- `requestmeta`: trusted-proxy resolution, HTTPS/origin metadata, and request IDs.
- `requestlog`: bounded versioned records, middleware, sinks, and private JSONL.
- `websec`: headers, origin checks, CSRF, redirects, body limits, and rate limits.
- `abuse`: application-classified request abuse with pluggable persistence.
- `auth`, `authhttp`, `authsqlite`: passwords, sessions, permissions, cookies,
and a no-CGO SQLite adapter.
- `analytics`: safe and sensitive aggregate projections over request records.
## Compose a request path
The copyable starter under `starters/basic` demonstrates the packages without
turning them into a router or template system.
Build middleware from the application outward. The request metadata resolver
is outermost so every package inside it observes the same request identity:
## Security boundary
```text
request
└─ requestmeta ─ websec ─ requestlog ─ your router and handlers
```
```go
var handler http.Handler = router
handler = requestlog.Middleware(sink, logPolicy)(handler)
handler = websec.Headers(headerPolicy)(handler)
handler = resolver.Middleware(handler)
```
The copyable [`starters/basic`](starters/basic) server demonstrates that
composition with loopback binding, graceful shutdown, and optional private
JSONL logging.
## Identity and access
- [`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,
enrollment, operation-bound fresh approval, and bounded recovery.
- [`authrecovery`](authrecovery) supports printable self-service recovery and
a separate owner-assisted flow that atomically replaces compromised account
credentials while writing both identity and organization-visible audits.
- [`organizations`](organizations) and [`access`](access) keep platform
operation separate from organization-data authority while supporting teams,
invitations, scoped roles, and audited temporary access.
See the [passkey integration guide](docs/PASSKEYS.md) and
[organization/access model](docs/ORGANIZATIONS.md) before exposing account or
administration routes.
## Security and assurance
Client addresses are accepted from forwarding headers only when the immediate
peer and every skipped proxy are explicitly trusted. Sensitive request fields
are off by default. Cryptographic entropy failures fail closed. Logs and account
databases remain private application data and never belong in source releases.
are off by default. Cryptographic entropy failures fail closed. Logs and
account databases remain private application data and never belong in source
releases.
See [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md),
the [application adoption contract](docs/ADOPTION.md), and
[docs/SERVICES_ROADMAP.md](docs/SERVICES_ROADMAP.md).
Every change is checked with formatting, tests, the race detector, vet,
dependency policy, licence policy, public-snapshot allowlisting, and a
reproducible starter build. Scheduled assurance adds vulnerability scanning and
bounded fuzz campaigns.
## Licensing
Read [SECURITY.md](SECURITY.md), the [threat model](docs/THREAT_MODEL.md),
[adoption contract](docs/ADOPTION.md), and
[dependency boundary](docs/DEPENDENCIES.md) before production adoption.
This is a multi-license repository with exact file-level SPDX identifiers:
## HTML and templates
- embeddable packages and adapters: MPL-2.0;
- future standalone network services and operational machinery: AGPL-3.0-only;
- starters, examples, and reusable configuration: 0BSD.
Web Foundations deliberately does not provide a template language. Sandwich
Hime is the preferred companion for Gamertan applications that want HTML-first,
typed, ahead-of-time Go templates. The projects remain independently usable.
See [LICENSES.md](LICENSES.md). No standalone auth or logging server is included
in this preview.
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md) and the official
[first-site tutorial](https://sandwichhime.com/docs/tutorial/).
## Source, support, and licensing
Canonical source, issues, security policy, and release notes live on
[Speelman Forge](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin.
The libraries and adapters are MPL-2.0. Starters and reusable examples are
0BSD. Future standalone services and operational machinery are
AGPL-3.0-only. Exact file-level SPDX identifiers remain authoritative; see the
[licensing map](LICENSES.md) and [third-party notices](THIRD_PARTY_NOTICES.md).
+13
View File
@@ -14,3 +14,16 @@ service-level agreement. There is no bug bounty.
The preview supports only versions explicitly listed in release notes. Security
claims stop at the documented trust boundaries and executable tests.
Password recovery is an explicitly local administrative capability. It must
not be wired directly to a public route. Applications using it are responsible
for local operator authorization and exclusive mode-`0600` credential delivery;
the library transaction requires a new password change, revokes all sessions,
and records a secret-free audit event.
Passkey recovery is also local-only. Applications must not expose bootstrap or
recovery issuance as a public route. Enrollment tokens are single-use,
short-lived, digest-backed values and their plaintext belongs only in an
exclusive mode-`0600` delivery file. A passkey assertion proves control of a
credential; applications must still bind sensitive actions to exact server-side
state and authorize the resulting principal.
+38
View File
@@ -0,0 +1,38 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Third-party notices
## go-webauthn
- Module: `github.com/go-webauthn/webauthn`
- Version: `v0.17.1`
- Source commit: `de0a809e3027957ca15b72b252540317f9ba581b`
- Module sum: `h1:N8/ycHNeibifKhG+0ZFuQZsDvYiNRE5UpukUc8hb+k4=`
- Go module sum: `h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=`
- Downloaded module ZIP SHA-256:
`6f1e06307fdc998087675db3a6cb5f133fdf7b91ac0a4ced689c336a5f28a91e`
- Licence: BSD-3-Clause; the upstream licence text is preserved in
`LICENSES/BSD-3-Clause-go-webauthn.txt`, the unchanged audit source, and the
compiled internal derivative.
The complete upstream module is retained unchanged at
`third_party/go-webauthn`. `third_party/go-webauthn.SHA256SUMS` records every
source file. The required non-test packages are compiled from
`internal/webauthnvendored`; a deterministic gate derives that tree from the
audited source, rewrites only the self-import prefix, and requires an exact
match. The public module contains no local replacement because downstream Go
modules do not honor dependency replacement directives.
The module performs WebAuthn protocol parsing, CBOR/COSE handling, attestation
and assertion verification, and signature-counter updates. Web Foundations
retains relying-party policy, storage, sessions, recovery, operation binding,
and application authorization.
Transitive modules and their exact checksums are recorded in `go.mod` and
`go.sum`. Release assurance runs `go mod verify`, licence-boundary checks,
`govulncheck`, race tests, and bounded malformed-response fuzzing.
The 2026-08-19 Go 1.26.6 `govulncheck` review found no reachable
vulnerabilities. It reported `GO-2026-5932` against the unmaintained
`golang.org/x/crypto/openpgp` package at the module level; Web Foundations uses
`argon2` and does not import or call `openpgp`.
+354
View File
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: MPL-2.0
// Package access defines organization-scoped role bindings and audited,
// short-lived break-glass authorization.
package access
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
var (
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("access: a current direct owner must approve owner role changes")
ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
type SubjectKind string
const (
User SubjectKind = "user"
Team SubjectKind = "team"
)
type Scope struct {
OrganizationID string
ProjectID string
EnvironmentID string
ServiceID string
}
func (scope Scope) Validate() error {
if !idPattern.MatchString(scope.OrganizationID) || scope.ProjectID != "" && !idPattern.MatchString(scope.ProjectID) || scope.EnvironmentID != "" && !idPattern.MatchString(scope.EnvironmentID) || scope.ServiceID != "" && !idPattern.MatchString(scope.ServiceID) {
return errors.New("access: invalid scope")
}
if scope.EnvironmentID != "" && scope.ProjectID == "" || scope.ServiceID != "" && scope.EnvironmentID == "" {
return errors.New("access: incomplete scope hierarchy")
}
return nil
}
func (scope Scope) contains(requested Scope) bool {
if scope.OrganizationID != requested.OrganizationID {
return false
}
for _, pair := range [][2]string{{scope.ProjectID, requested.ProjectID}, {scope.EnvironmentID, requested.EnvironmentID}, {scope.ServiceID, requested.ServiceID}} {
if pair[0] != "" && pair[0] != pair[1] {
return false
}
}
return true
}
type Binding struct {
ID string
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
GrantedAt time.Time
}
type Policy struct {
Roles map[string]string
Permissions map[string]string
Grants map[string][]string
}
func (policy Policy) Validate() error {
if len(policy.Roles) == 0 || len(policy.Roles) > 1000 || len(policy.Permissions) == 0 || len(policy.Permissions) > 10000 || len(policy.Grants) > 1000 {
return errors.New("access: invalid policy size")
}
for name, description := range policy.Roles {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid role")
}
}
for name, description := range policy.Permissions {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid permission")
}
}
for role, permissions := range policy.Grants {
if _, ok := policy.Roles[role]; !ok || len(permissions) > 10000 {
return errors.New("access: invalid role grant")
}
for _, permission := range permissions {
if _, ok := policy.Permissions[permission]; !ok {
return errors.New("access: role references unknown permission")
}
}
}
return nil
}
type BreakGlass struct {
ID, OrganizationID, UserID, Permission, Reason string
CreatedAt, ExpiresAt time.Time
}
type AuditEvent struct {
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
CreatedAt time.Time
}
type Repository interface {
SeedAccessPolicy(context.Context, Policy) error
Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, error)
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error
AccessAudit(context.Context, string, int) ([]AuditEvent, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
OwnerRole string
}
type Service struct {
repository Repository
policy Policy
random io.Reader
now func() time.Time
ownerRole string
}
func New(repository Repository, policy Policy, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("access: repository is required")
}
if err := policy.Validate(); err != nil {
return nil, err
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.OwnerRole != "" {
if _, ok := policy.Roles[options.OwnerRole]; !ok {
return nil, errors.New("access: owner role is unknown")
}
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
}
func (service *Service) Seed(ctx context.Context) error {
return service.repository.SeedAccessPolicy(ctx, service.policy)
}
type Grant struct {
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
}
func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error) {
if (input.SubjectKind != User && input.SubjectKind != Team) || !idPattern.MatchString(input.SubjectID) || !idPattern.MatchString(input.GrantedBy) {
return Binding{}, errors.New("access: invalid binding subject")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
if err := input.Scope.Validate(); err != nil {
return Binding{}, err
}
id, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
binding := Binding{ID: id, SubjectKind: input.SubjectKind, SubjectID: input.SubjectID, Role: input.Role, Scope: input.Scope, GrantedBy: input.GrantedBy, GrantedAt: service.now().UTC()}
if err = service.repository.Grant(ctx, binding); err != nil {
return Binding{}, err
}
return binding, nil
}
// OrganizationUserBindings lists active, direct, organization-wide user role
// bindings. Team and narrower project/environment/service grants remain
// separate because an administration screen must not silently flatten their
// authority into one apparent role.
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("access: invalid organization binding query")
}
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
}
type OrganizationUserRoleChange struct {
OrganizationID string
UserID string
Role string
ActorUserID string
RequestID string
ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also requires a current active
// direct owner for any change to or from that role and protects the final
// active direct owner in the same transaction.
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement")
}
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
return Binding{}, errors.New("access: invalid organization role replacement")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return Binding{}, err
}
bindingID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
now := service.now().UTC()
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
return Binding{}, err
}
return binding, nil
}
type Decision struct {
Allowed bool
Source string
Role string
}
func (service *Service) Authorize(ctx context.Context, userID string, scope Scope, permission string) (Decision, error) {
if !idPattern.MatchString(userID) || !namePattern.MatchString(permission) {
return Decision{}, errors.New("access: invalid authorization request")
}
if err := scope.Validate(); err != nil {
return Decision{}, err
}
if _, ok := service.policy.Permissions[permission]; !ok {
return Decision{}, errors.New("access: unknown permission")
}
bindings, err := service.repository.EffectiveBindings(ctx, scope.OrganizationID, userID)
if err != nil {
return Decision{}, err
}
sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID })
for _, binding := range bindings {
if !binding.Scope.contains(scope) {
continue
}
for _, granted := range service.policy.Grants[binding.Role] {
if granted == permission {
return Decision{Allowed: true, Source: "role", Role: binding.Role}, nil
}
}
}
breakGlass, err := service.repository.ActiveBreakGlass(ctx, scope.OrganizationID, userID, service.now().UTC())
if err != nil {
return Decision{}, err
}
for _, grant := range breakGlass {
if grant.Permission == permission {
return Decision{Allowed: true, Source: "break_glass"}, nil
}
}
return Decision{}, nil
}
func (service *Service) ActivateBreakGlass(ctx context.Context, organizationID, userID, permission, reason, requestID string, lifetime time.Duration) (BreakGlass, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !namePattern.MatchString(permission) || !text(strings.TrimSpace(reason), 1024, false) || !text(requestID, 128, true) || lifetime < time.Minute || lifetime > time.Hour {
return BreakGlass{}, errors.New("access: invalid break-glass request")
}
if _, ok := service.policy.Permissions[permission]; !ok {
return BreakGlass{}, errors.New("access: unknown permission")
}
id, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
now := service.now().UTC()
grant := BreakGlass{ID: id, OrganizationID: organizationID, UserID: userID, Permission: permission, Reason: strings.TrimSpace(reason), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
audit := AuditEvent{ID: auditID, OrganizationID: organizationID, ActorUserID: userID, Action: "break_glass.activate", ResourceType: "organization", ResourceID: organizationID, RequestID: requestID, Summary: "Temporary emergency access activated", CreatedAt: now}
if err = service.repository.CreateBreakGlass(ctx, grant, audit); err != nil {
return BreakGlass{}, err
}
return grant, nil
}
func (service *Service) Audit(ctx context.Context, organizationID string, limit int) ([]AuditEvent, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("access: invalid audit query")
}
return service.repository.AccessAudit(ctx, organizationID, limit)
}
func randomID(random io.Reader) (string, error) {
value := make([]byte, 18)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("access: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalBindingIDs(values []string) ([]string, error) {
if len(values) > 16 {
return nil, errors.New("access: invalid expected role bindings")
}
result := append([]string(nil), values...)
sort.Strings(result)
for index, value := range result {
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
return nil, errors.New("access: invalid expected role bindings")
}
}
return result, nil
}
func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
+140
View File
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
)
func TestScopedRoleAndBreakGlass(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &repositoryStub{}
policy := Policy{Roles: map[string]string{"viewer": "Read safe telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
scope := Scope{OrganizationID: "org-12345678", ProjectID: "project-12345678"}
binding, err := service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: scope, GrantedBy: "user-87654321"})
if err != nil {
t.Fatal(err)
}
repository.bindings = []Binding{binding}
decision, err := service.Authorize(t.Context(), "user-12345678", Scope{OrganizationID: scope.OrganizationID, ProjectID: scope.ProjectID, EnvironmentID: "env-12345678"}, "telemetry.read")
if err != nil || !decision.Allowed || decision.Source != "role" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
if err != nil || decision.Allowed {
t.Fatalf("unexpected sensitive decision=%+v err=%v", decision, err)
}
grant, err := service.ActivateBreakGlass(t.Context(), scope.OrganizationID, "user-12345678", "telemetry.sensitive.read", "Investigate active incident", "request-12345678", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
repository.breakGlass = []BreakGlass{grant}
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
}
}
func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
policy := Policy{Roles: map[string]string{"viewer": ""}, Permissions: map[string]string{"telemetry.read": ""}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
service, err := New(&repositoryStub{}, policy, Options{Random: strings.NewReader(strings.Repeat("x", 256))})
if err != nil {
t.Fatal(err)
}
if _, err = service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: Scope{OrganizationID: "org-12345678", EnvironmentID: "env-12345678"}, GrantedBy: "user-87654321"}); err == nil {
t.Fatal("incomplete hierarchy accepted")
}
if _, err = service.ActivateBreakGlass(t.Context(), "org-12345678", "user-12345678", "telemetry.read", "reason", "", 2*time.Hour); err == nil {
t.Fatal("unbounded break-glass lifetime accepted")
}
}
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
now := time.Unix(2000, 0).UTC()
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
t.Fatal("unknown owner role accepted")
}
repository := &repositoryStub{}
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
OrganizationID: "org-12345678",
UserID: "user-12345678",
Role: "viewer",
ActorUserID: "user-87654321",
RequestID: "request-12345678",
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
})
if err != nil {
t.Fatal(err)
}
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
t.Fatalf("binding=%+v", binding)
}
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
}
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
}
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
t.Fatal("duplicate expected binding accepted")
}
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
if err != nil {
t.Fatal(err)
}
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
t.Fatalf("missing owner role err=%v", err)
}
}
type repositoryStub struct {
bindings []Binding
breakGlass []BreakGlass
organizationUser []Binding
replacedExpected []string
replacedBinding Binding
replacedOwnerRole string
replacedAccessAudit AuditEvent
}
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
func (*repositoryStub) Grant(context.Context, Binding) error { return nil }
func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error { return nil }
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
return repository.bindings, nil
}
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
return repository.organizationUser, nil
}
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
repository.replacedExpected = append([]string(nil), expected...)
repository.replacedBinding = binding
repository.replacedOwnerRole = ownerRole
repository.replacedAccessAudit = audit
return nil
}
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
repository.breakGlass = []BreakGlass{grant}
return nil
}
func (repository *repositoryStub) ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) {
return repository.breakGlass, nil
}
func (*repositoryStub) AppendAccessAudit(context.Context, AuditEvent) error { return nil }
func (*repositoryStub) AccessAudit(context.Context, string, int) ([]AuditEvent, error) {
return nil, nil
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"sort"
)
var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported")
// RoleSetRepository commits every replacement and the audit atomically. There
// is no sequence of individual Grant/Revoke calls as a fallback.
type RoleSetRepository interface {
ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error
}
type OrganizationUserRolesChange struct {
OrganizationID, UserID, ActorUserID, RequestID string
Roles, ExpectedBindingIDs []string
}
// ReplaceOrganizationUserRoles replaces the direct organization-wide role set
// for one active member. Team and narrower grants are unaffected. This bulk
// operation requires a current direct owner inside the write transaction;
// applications still authorize their customer/merchant and allowed-role boundary.
func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) {
repository, ok := service.repository.(RoleSetRepository)
if !ok {
return nil, ErrRoleSetUnsupported
}
if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 {
return nil, errors.New("access: invalid organization role set")
}
roles := append([]string(nil), input.Roles...)
sort.Strings(roles)
for i, role := range roles {
if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role {
return nil, errors.New("access: unknown or duplicate role")
}
}
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
if err != nil {
return nil, err
}
now := service.now().UTC()
bindings := make([]Binding, 0, len(roles))
for _, role := range roles {
id, err := randomID(service.random)
if err != nil {
return nil, err
}
bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now})
}
id, err := randomID(service.random)
if err != nil {
return nil, err
}
audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now}
if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil {
return nil, err
}
return bindings, nil
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
)
type roleSetRepositoryStub struct {
repositoryStub
calls int
expected []string
roles []Binding
audit AuditEvent
}
func (r *roleSetRepositoryStub) ReplaceOrganizationUserRoles(_ context.Context, expected []string, bindings []Binding, _ string, audit AuditEvent) error {
r.calls++
r.expected, r.roles, r.audit = expected, bindings, audit
return nil
}
func TestRoleSetServiceBoundsAndCanonicalCopies(t *testing.T) {
policy := Policy{Roles: map[string]string{"owner": "Owner", "buyer": "Buyer", "billing": "Billing"}, Permissions: map[string]string{"purchase": "Purchase"}, Grants: map[string][]string{"owner": {"purchase"}, "buyer": {"purchase"}, "billing": {}}}
r := &roleSetRepositoryStub{}
service, err := New(r, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
input := OrganizationUserRolesChange{OrganizationID: "organization-123", UserID: "member-12345678", ActorUserID: "owner-12345678", Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: []string{"binding-second", "binding-first"}, RequestID: "request-roles"}
bindings, err := service.ReplaceOrganizationUserRoles(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if r.calls != 1 || len(bindings) != 2 || bindings[0].Role != "billing" || bindings[1].Role != "buyer" || bindings[0].ID == bindings[1].ID || !slices.Equal(r.expected, []string{"binding-first", "binding-second"}) {
t.Fatalf("bindings=%v expected=%v calls=%d", bindings, r.expected, r.calls)
}
if !slices.Equal(input.Roles, []string{"buyer", "billing"}) || !slices.Equal(input.ExpectedBindingIDs, []string{"binding-second", "binding-first"}) {
t.Fatal("caller input was sorted in place")
}
if r.audit.RequestID != input.RequestID || r.audit.ActorUserID != input.ActorUserID || r.audit.ResourceID != input.UserID {
t.Fatalf("audit=%+v", r.audit)
}
for _, roles := range [][]string{nil, {"buyer", "buyer"}, {"missing"}, make([]string, 17)} {
invalid := input
invalid.Roles = roles
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid roles=%v", roles)
}
}
for _, expected := range [][]string{{"bad"}, {"binding-first", "binding-first"}, make([]string, 17)} {
invalid := input
invalid.ExpectedBindingIDs = expected
if _, err = service.ReplaceOrganizationUserRoles(t.Context(), invalid); err == nil {
t.Fatalf("invalid IDs=%v", expected)
}
}
if r.calls != 1 {
t.Fatal("invalid input reached repository")
}
legacy, err := New(&repositoryStub{}, policy, Options{OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if _, err = legacy.ReplaceOrganizationUserRoles(t.Context(), input); !errors.Is(err, ErrRoleSetUnsupported) {
t.Fatalf("fallback=%v", err)
}
broken, err := New(r, policy, Options{OwnerRole: "owner", Random: strings.NewReader("")})
if err != nil {
t.Fatal(err)
}
if _, err = broken.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("random failure reached storage")
}
withoutOwner, err := New(r, policy, Options{Now: func() time.Time { return time.Unix(2000, 0) }})
if err != nil {
t.Fatal(err)
}
if _, err = withoutOwner.ReplaceOrganizationUserRoles(t.Context(), input); err == nil || r.calls != 1 {
t.Fatal("role set without owner boundary accepted")
}
}
+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
}
+171 -16
View File
@@ -21,6 +21,7 @@ import (
var (
ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active")
ErrPasswordUnchanged = errors.New("auth: new password must differ from the current password")
ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
@@ -29,8 +30,15 @@ var (
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
@@ -59,6 +67,9 @@ type PolicySeed struct {
type Repository interface {
CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error)
CredentialByUserID(context.Context, string) (User, string, error)
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error
UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
@@ -102,7 +113,17 @@ func New(repository Repository, options Options) (*Service, error) {
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
}
type CreateUser struct{ Username, Email, DisplayName, Password string }
type CreateUser struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
// AdministrativePasswordReset describes a locally authorized recovery. The
// application is responsible for delivering TemporaryPassword through a
// private, one-time channel; the value must never be logged or audited.
type AdministrativePasswordReset struct {
Identifier, TemporaryPassword string
}
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username)
@@ -120,31 +141,149 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User,
return User{}, err
}
now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now, PasswordChangeRequired: input.RequirePasswordChange}
if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err
}
return user, nil
}
// GenerateTemporaryPassword returns 256 bits of URL-safe cryptographic
// entropy suitable for an application-managed one-time bootstrap credential.
func GenerateTemporaryPassword(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
return randomToken(random, 32)
}
// ChangePassword verifies the current credential, rejects reuse, replaces the
// Argon2id hash, clears the password-change requirement, and revokes every
// existing session through one repository operation.
func (service *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, currentHash, err := service.repository.CredentialByUserID(ctx, strings.TrimSpace(userID))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if !user.Active() {
return ErrInactiveUser
}
if currentPassword == newPassword {
return ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(newPassword, service.random)
if err != nil {
return err
}
if err = service.repository.ReplacePasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, service.now().UTC()); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("auth: replace password: %w", err)
}
return nil
}
// ResetPassword replaces an active user's credential without requiring the
// current password. It is intended only for a locally authorized
// administrative recovery command. The repository atomically requires another
// password change, revokes all sessions, and appends a secret-free audit event.
func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) {
identifier := strings.TrimSpace(input.Identifier)
user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier)
if errors.Is(err, ErrUserNotFound) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
}
if !user.Active() {
return User{}, ErrInactiveUser
}
if VerifyPassword(currentHash, input.TemporaryPassword) {
return User{}, ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random)
if err != nil {
return User{}, err
}
auditID, err := randomToken(service.random, 18)
if err != nil {
return User{}, err
}
now := service.now().UTC()
audit := AuditEvent{
ID: auditID,
Action: "auth.password.reset",
ResourceType: "user",
ResourceID: user.ID,
Summary: "A local administrator issued a one-time credential and revoked all sessions.",
CreatedAt: now,
}
if err = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return User{}, ErrInvalidCredentials
}
return User{}, fmt.Errorf("auth: reset password: %w", err)
}
user.PasswordChangeRequired = true
user.UpdatedAt = now
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)
return "", Principal{}, err
}
if !VerifyPassword(hash, password) {
return "", Principal{}, ErrInvalidCredentials
return service.IssueSession(ctx, user.ID, lifetime)
}
// IssueSession creates an opaque session for an already authenticated user.
// Authentication mechanisms such as passkeys call this only after completing
// their credential verification. The repository remains authoritative for the
// account's current status and permissions.
func (service *Service) IssueSession(ctx context.Context, userID 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")
}
if user.Status != "active" {
return "", Principal{}, ErrInactiveUser
userID = strings.TrimSpace(userID)
if !opaqueID(userID) {
return "", Principal{}, errors.New("auth: invalid user id")
}
token, err := randomToken(service.random, 32)
if err != nil {
@@ -152,15 +291,19 @@ func (service *Service) Authenticate(ctx context.Context, identifier, password s
}
now := service.now().UTC()
digest := sha256.Sum256([]byte(token))
if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil {
if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil {
return "", Principal{}, err
}
_ = service.repository.UpdateLastLogin(ctx, user.ID, now)
_ = service.repository.UpdateLastLogin(ctx, userID, now)
principal, _, err := service.repository.PrincipalBySession(ctx, digest, now)
if err != nil {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err
}
if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, ErrInactiveUser
}
return token, principal, nil
}
@@ -177,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
}
@@ -211,6 +354,18 @@ func randomToken(random io.Reader, bytes int) (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil
}
func opaqueID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if !(character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
return false
}
}
return true
}
func sortedUnique(values []string) []string {
set := make(map[string]struct{}, len(values))
for _, value := range values {
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
)
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
// Limit defaults to 50 and may not exceed 200.
type UserDirectoryQuery struct {
Search, AfterID string
Limit int
}
type UserDirectoryPage struct {
Users []User
NextID string
}
// UserDirectoryRepository is an optional administrative read capability, not an
// extension of ordinary authentication. Callers MUST authorize instance-wide
// identity access before each call. Results include incomplete/inactive accounts
// but never credentials, session material, recovery codes or permission grants.
// Pagination is a current view, not a snapshot across requests.
type UserDirectoryRepository interface {
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
}
+13
View File
@@ -28,6 +28,19 @@ func TestPasswordEntropyFailsClosed(t *testing.T) {
}
}
func TestTemporaryPasswordUsesBoundedCryptographicEntropy(t *testing.T) {
password, err := GenerateTemporaryPassword(strings.NewReader(strings.Repeat("t", 32)))
if err != nil {
t.Fatal(err)
}
if len(password) != 43 || ValidatePassword(password) != nil || strings.ContainsAny(password, " \t\r\n") {
t.Fatalf("temporary password length=%d", len(password))
}
if _, err = GenerateTemporaryPassword(errorReader{}); err == nil {
t.Fatal("temporary password accepted entropy failure")
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
ErrProfileInput = errors.New("auth: invalid profile change")
ErrProfileAccess = errors.New("auth: profile session is unavailable")
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
)
// OwnProfile contains mutable identity, not credentials or organization roles.
// Revision is independent of timestamps and increases for every profile edit.
type OwnProfile struct {
UserID, Username, Email, DisplayName string
Revision int64
}
// ProfileEdit is a trusted repository command, not an HTTP input model. The
// application must authenticate the session, validate CSRF/origin and rate-limit
// mutations. Username edits additionally require recent reauthentication (and
// any account-specific MFA). For password reauthentication, supply the verified
// hash so a concurrent password reset invalidates the write. After verified
// passkey approval, leave it empty. Do not log or serialize this command.
type ProfileEdit struct {
UserID string
SessionDigest [32]byte
ExpectedRevision int64
Field, Value string
ExpectedPasswordHash string
}
// NormalizeProfileValue validates only supported fields. Email is deliberately
// absent: verified mailbox changes need a separate pending/confirmation flow.
func NormalizeProfileValue(field, value string) (string, error) {
value = strings.TrimSpace(value)
switch field {
case "username":
if !identifierPattern.MatchString(value) {
return "", ErrProfileInput
}
case "display_name":
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
return "", ErrProfileInput
}
for _, r := range value {
if unicode.IsControl(r) {
return "", ErrProfileInput
}
}
default:
return "", ErrProfileInput
}
return value, nil
}
// OwnProfileRepository is optional; no change to the authentication Repository
// interface is required. It derives access from the current session, never from
// a site-wide administrator flag. Implementations atomically recheck identity,
// session and revision, mutate one field, and append the audit. Username edits
// revoke other sessions but preserve the acting session. They never reassign
// stable IDs, memberships, passkeys, billing identities or historical records.
type OwnProfileRepository interface {
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MPL-2.0
package auth
import "testing"
func TestNormalizeProfileValue(t *testing.T) {
for _, value := range []struct{ field, value, want string }{
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
} {
got, err := NormalizeProfileValue(value.field, value.value)
if err != nil || got != value.want {
t.Fatalf("normalization: %q %v", got, err)
}
}
for _, value := range []struct{ field, value string }{
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
} {
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
t.Fatalf("invalid field accepted: %s", value.field)
}
}
}
func FuzzProfileValue(f *testing.F) {
f.Add("username", "reader.one")
f.Add("display_name", "Émilie")
f.Add("email", "a@example.test")
f.Fuzz(func(t *testing.T, field, value string) {
normal, err := NormalizeProfileValue(field, value)
if err != nil {
return
}
if len(normal) == 0 || len(normal) > 128 {
t.Fatal("unbounded value")
}
again, err := NormalizeProfileValue(field, normal)
if err != nil || again != normal {
t.Fatal("unstable normalization")
}
})
}
+78
View File
@@ -43,11 +43,80 @@ func TestRevokeSessionRejectsInvalidTokenBeforeStorage(t *testing.T) {
}
}
func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
repository := &activeSessionRepository{principal: Principal{User: User{ID: "valid-user-id", Status: "disabled"}}}
service, err := New(repository, Options{})
if err != nil {
t.Fatal(err)
}
if _, _, err = service.IssueSession(t.Context(), "valid-user-id", time.Hour); !errors.Is(err, ErrInactiveUser) {
t.Fatalf("err=%v", err)
}
if !repository.deleted {
t.Fatal("inactive session was not deleted")
}
}
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
}
type activeSessionRepository struct {
repositoryStub
principal Principal
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
}
func (repository *activeSessionRepository) DeleteSession(context.Context, [32]byte) error {
repository.deleted = true
return nil
}
func (repository *recordingRepository) DeleteSession(context.Context, [32]byte) error {
repository.deleted = true
return nil
@@ -59,6 +128,15 @@ func (repositoryStub) CreateUser(context.Context, User, string) error { return n
func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) CredentialByUserID(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (repositoryStub) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
+9
View File
@@ -109,6 +109,15 @@ func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error {
func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (authHTTPRepository) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) error {
return nil
}
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"gamertan.com/web/authwebauthn"
)
const maxPasskeyBodyBytes = 160 << 10
type PasskeyFinish struct {
CeremonyToken string `json:"ceremony_token"`
Credential json.RawMessage `json:"credential"`
}
func WritePasskeyBegin(response http.ResponseWriter, result authwebauthn.BeginResult) error {
if len(result.CeremonyToken) < 32 || len(result.PublicKey) == 0 || !json.Valid(result.PublicKey) || result.ExpiresAt.IsZero() {
return errors.New("authhttp: invalid passkey ceremony")
}
response.Header().Set("Cache-Control", "no-store")
response.Header().Set("Content-Type", "application/json; charset=utf-8")
response.Header().Set("X-Content-Type-Options", "nosniff")
encoder := json.NewEncoder(response)
encoder.SetEscapeHTML(true)
return encoder.Encode(result)
}
func ReadPasskeyFinish(request *http.Request) (PasskeyFinish, error) {
if request == nil || request.Method != http.MethodPost {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires POST")
}
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires application/json")
}
body, err := io.ReadAll(io.LimitReader(request.Body, maxPasskeyBodyBytes+1))
if err != nil || len(body) > maxPasskeyBodyBytes {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var input PasskeyFinish
if err = decoder.Decode(&input); err != nil {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return PasskeyFinish{}, errors.New("authhttp: passkey response contains trailing data")
}
input.CeremonyToken = strings.TrimSpace(input.CeremonyToken)
input.Credential = bytes.TrimSpace(input.Credential)
if len(input.CeremonyToken) < 32 || len(input.CeremonyToken) > 128 || len(input.Credential) == 0 || len(input.Credential) > maxPasskeyBodyBytes || !json.Valid(input.Credential) {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
return input, nil
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyJSONBoundary(t *testing.T) {
result := authwebauthn.BeginResult{CeremonyToken: strings.Repeat("a", 43), PublicKey: []byte(`{"challenge":"example"}`), ExpiresAt: time.Now().UTC().Add(time.Minute)}
recorder := httptest.NewRecorder()
if err := WritePasskeyBegin(recorder, result); err != nil {
t.Fatal(err)
}
if recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("headers=%v", recorder.Header())
}
request := httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/passkey/finish", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{"id":"x"}}`))
request.Header.Set("Content-Type", "application/json")
finish, err := ReadPasskeyFinish(request)
if err != nil {
t.Fatal(err)
}
if finish.CeremonyToken == "" || string(finish.Credential) != `{"id":"x"}` {
t.Fatalf("finish=%+v", finish)
}
}
func TestPasskeyJSONRejectsWrongMethodUnknownFieldsAndOversize(t *testing.T) {
for name, request := range map[string]*http.Request{
"method": httptest.NewRequest(http.MethodGet, "https://tend.gamertan.com/", nil),
"unknown": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{},"extra":true}`)),
"large": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(strings.Repeat("x", maxPasskeyBodyBytes+1))),
} {
t.Run(name, func(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
if _, err := ReadPasskeyFinish(request); err == nil {
t.Fatal("accepted invalid request")
}
})
}
}
+83
View File
@@ -0,0 +1,83 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Account mail protocols
`authmail` provides optional verification, confirmed address changes and password
reset. It does not deliver mail directly or own the application router. Use
`authsqlite.Store.AccountMail` for an adapter sharing the identity database and
encrypted transactional outbox. Call `MigrateMail` explicitly and require its
independent schema version before enabling these routes. Base identity schema 11
and historical commerce records are unchanged; existing mailboxes start unverified.
## Operations
- Verification requires a current unrestricted account session and confirmation
at its existing canonical mailbox. Reading/inspecting a link does not verify it.
- Address change requires the current password and that session, then separate
confirmation at both current and proposed mailboxes. The current address stays
authoritative until both confirm. No passkey-approval boolean bypass exists.
A future passkey-only path needs a separately bound fresh-approval protocol.
- Password reset is available only through the current verified mailbox of an
active, fully registered account. Request results are generic for unknown,
unverified, inactive, malformed and account-throttled addresses. A reset creates
no login, removes no passkey/recovery code and bypasses no existing MFA policy.
Tokens use 32 random bytes and purpose-bound SHA-256 digests, expire in 15 minutes,
and work once. Requests bind the user ID, canonical address, profile revision and
current password digest; address/verification requests also bind the real acting
session. A replacement request invalidates the previous link for that purpose.
Already-in-flight older mail may still arrive; an invalidated token cannot act.
The SQLite adapter rechecks authority and identity under its writer lock, including
fresh time after waits/password hashing. A changed password, profile, address,
status or acting session rejects stale requests. Address uniqueness is checked
again at final confirmation. Old-address invitations are revoked, not moved;
new-address invitations still need normal token/authority checks to be accepted.
Existing memberships, ownership and purchase snapshots retain the immutable user.
Successful reset/address changes revoke sessions, pending ceremonies, enrollment
and recovery grants, and outstanding account-mail requests. Mailbox changes notify
both addresses; resets notify the current mailbox. Account changes, notifications
and secret-free audits commit atomically. Delivery capacity/audit failures roll
everything back, leaving valid tokens retryable until their original expiry.
Per-account requests are limited to one per minute and five per hour across these
purposes. At most one pending request per account/purpose remains. Applications
must also impose IP and password-hashing concurrency limits and handle anonymous
request responses without disclosing per-account operational failures.
## Required application boundaries
- Use a configured HTTPS origin and fixed route paths. Never build links from a
request Host header. `Composer` customizes reviewed plain-text copy, not security
state, sender, recipient, headers or editable executable templates.
- Render a deliberate confirmation/reset form; only POST consumes a token. Keep
strict same-origin/CSRF protection and private/no-store responses. Prefer
`TokenInFragment` so the browser transfers the code into the deliberate POST
without placing it in HTTP/proxy request targets. Native forms can require a
same-origin referrer policy to retain a usable Origin header; fragments are
never included in referrers. Script-only flows can use no-referrer. Do not
weaken origin validation to accept opaque/null origins, and never allow link
scanners or GET requests to change account state.
- Exclude tokens, query strings, message bodies, addresses, passwords and SMTP
credentials from logs/telemetry. Restrict token-bearing pages and avoid external
analytics/resources. Debug redaction does not make structured serialization safe.
- Return anonymous request responses consistently, independent of eligibility or
SMTP acceptance. Run SMTP through the bounded outbox worker, not in the request.
- After reset/change, clear the browser's old session and return to normal login.
Preserve the application's passkey/MFA checks; mailbox control is not an owner
recovery grant. Keep printed/owner-assisted recovery separate.
- Existing Stripe receipts/billing emails are financial snapshots, not canonical
login identifiers. Do not rewrite them as part of an account email change.
The design follows the applicable OWASP guidance on
[password reset](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html)
and [registered-email changes](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address).
This is not a claim of a completed application security audit.
Local tests cover both confirmation orders, replay/expiry, generic reset requests,
credential/session races, competing resets, address conflicts, stable ownership,
retained factors, invitation handling, rate limits, restart and transactional
migration/audit/outbox rollback. The account packages and mail/outbox suites pass;
focused races and vet pass. Consumer HTTP/UI integration, release/publication and
controlled real-mail proof remain pending in the repository queue.
+455
View File
@@ -0,0 +1,455 @@
// SPDX-License-Identifier: MPL-2.0
// Package authmail implements mailbox verification and password-reset protocols.
// Applications own HTTP CSRF/origin checks, IP/concurrency limits and templates.
// A confirmation must be a deliberate POST, never a mail-scanner-triggered GET.
package authmail
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
stdmail "net/mail"
"net/url"
"path"
"strings"
"time"
"unicode"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
var (
ErrInvalid = errors.New("authmail: invalid request")
ErrUnavailable = errors.New("authmail: link or account unavailable")
ErrLimited = errors.New("authmail: please wait before requesting another message")
ErrAddressUnavailable = errors.New("authmail: address cannot be used")
)
type Purpose string
const (
Verify Purpose = "verify"
Change Purpose = "change"
Reset Purpose = "reset"
Lifetime = 15 * time.Minute
)
// Subject is a repository/service boundary, not a public response or log value.
// PasswordHash is needed for reauthentication and credential-race checks.
type Subject struct {
UserID, Email, PasswordHash string
Revision int64
Verified bool
}
func (Subject) String() string { return "authmail.Subject{identity:redacted}" }
func (subject Subject) GoString() string { return subject.String() }
type Request struct {
ID, UserID, Email, NewEmail string
Purpose Purpose
Revision int64
CredentialDigest, SessionDigest, NewDigest, OldDigest [32]byte
CreatedAt, ExpiresAt time.Time
}
func (Request) String() string { return "authmail.Request{identity:redacted}" }
func (request Request) GoString() string { return request.String() }
type Pending struct {
Request Request
Subject Subject
OldToken bool
}
func (Pending) String() string { return "authmail.Pending{identity:redacted}" }
func (pending Pending) GoString() string { return pending.String() }
// Repository must atomically recheck current account/session/credential identity,
// throttling, token expiry and replay; mail intent and audit join each mutation.
// Implementations must never grant authentication or remove MFA credentials.
type Repository interface {
OwnSubject(context.Context, [32]byte, time.Time) (Subject, error)
ResetSubject(context.Context, string, time.Time) (Subject, error)
Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error
Pending(context.Context, [32]byte, time.Time) (Pending, error)
Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error)
}
// Config contains trusted deployment values, never a request Host header. The
// two route paths must render deliberate confirmation forms. Plain-text copy is
// centralized here; applications can provide a reviewed Composer to customize it.
type Config struct {
Origin, SiteName, ConfirmPath, ResetPath, SecurityPath string
// TokenInFragment keeps link tokens out of HTTP/proxy request targets. The
// application must transfer it locally into the deliberate confirmation POST.
TokenInFragment bool
Now func() time.Time
Random io.Reader
Compose Composer
}
type MessageContent struct{ Subject, Text string }
func (MessageContent) String() string { return "authmail.MessageContent{content:redacted}" }
func (content MessageContent) GoString() string { return content.String() }
type MailIntent struct {
Kind string
SiteName, ActionURL, SecurityURL string
ExpiresAt time.Time
}
func (MailIntent) String() string { return "authmail.MailIntent{links:redacted}" }
func (intent MailIntent) GoString() string { return intent.String() }
// Composer is trusted application code, not editable template code or arbitrary
// HTML. It does not choose recipients, sender, identifiers or security state.
type Composer func(MailIntent) (MessageContent, error)
type Service struct {
repository Repository
origin *url.URL
config Config
}
func New(repository Repository, config Config) (*Service, error) {
if repository == nil || !plainHeader(config.SiteName, 100) {
return nil, ErrInvalid
}
origin, err := url.Parse(config.Origin)
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") || origin.Opaque != "" {
return nil, ErrInvalid
}
for _, route := range []string{config.ConfirmPath, config.ResetPath, config.SecurityPath} {
if !strings.HasPrefix(route, "/") || strings.HasPrefix(route, "//") || strings.ContainsAny(route, "?#\\%") || !plainHeader(route, 256) || strings.TrimSuffix(route, "/") != path.Clean(route) {
return nil, ErrInvalid
}
}
if config.Now == nil {
config.Now = time.Now
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Compose == nil {
config.Compose = DefaultComposer
}
return &Service{repository: repository, origin: origin, config: config}, nil
}
func plainHeader(value string, limit int) bool {
if strings.TrimSpace(value) == "" || len(value) > limit {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
// NormalizeEmail preserves this identity store's case-insensitive mailbox rule.
// SMTPUTF8, display-name syntax and mailbox-provider alias rewriting are absent.
func NormalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if !plainHeader(value, 254) {
return "", ErrInvalid
}
for _, r := range value {
if r > 127 {
return "", ErrInvalid
}
}
address, err := stdmail.ParseAddress(value)
if err != nil || address.Name != "" || address.Address != value || !strings.Contains(value, "@") {
return "", ErrInvalid
}
return value, nil
}
func TokenDigest(token string) ([32]byte, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != token {
return [32]byte{}, ErrUnavailable
}
return sha256.Sum256([]byte("gwf.authmail.v1:" + token)), nil
}
func (service *Service) random(size int) (string, error) {
raw := make([]byte, size)
if _, err := io.ReadFull(service.config.Random, raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (service *Service) link(route, token string) string {
link := *service.origin
link.Path = route
link.RawQuery = ""
if token != "" {
if service.config.TokenInFragment {
link.Fragment = url.Values{"token": {token}}.Encode()
} else {
link.RawQuery = url.Values{"token": {token}}.Encode()
}
}
return link.String()
}
func (service *Service) message(kind, recipient, token string, now time.Time) (mail.Message, error) {
route := service.config.ConfirmPath
if kind == "reset" {
route = service.config.ResetPath
}
intent := MailIntent{Kind: kind, SiteName: service.config.SiteName, SecurityURL: service.link(service.config.SecurityPath, ""), ExpiresAt: now.Add(Lifetime)}
if token != "" {
intent.ActionURL = service.link(route, token)
}
content, err := service.config.Compose(intent)
if err != nil {
return mail.Message{}, ErrInvalid
}
id, err := service.random(18)
if err != nil {
return mail.Message{}, err
}
message := mail.Message{ID: "mail_" + id, To: recipient, Subject: content.Subject, Text: content.Text, CreatedAt: now}
if message.Validate() != nil {
return mail.Message{}, ErrInvalid
}
return message, nil
}
func (service *Service) audit(action, userID string, now time.Time) (auth.AuditEvent, error) {
id, err := service.random(18)
if err != nil {
return auth.AuditEvent{}, err
}
actor := userID
if strings.HasPrefix(action, "reset") {
actor = ""
}
return auth.AuditEvent{ID: "mailaudit_" + id, ActorUserID: actor, Action: "auth.mail." + action, ResourceType: "user", ResourceID: userID, Summary: "Account mail operation", CreatedAt: now}, nil
}
// Status requires a current unrestricted session; it is not an address lookup.
func (service *Service) Status(ctx context.Context, session [32]byte) (bool, error) {
subject, err := service.repository.OwnSubject(ctx, session, service.config.Now().UTC())
return subject.Verified, err
}
func (service *Service) RequestVerification(ctx context.Context, session [32]byte) error {
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if subject.Verified {
return nil
}
return service.issue(ctx, subject, session, Verify, "", now)
}
// RequestChange requires the current password and confirmations at BOTH old and
// new mailboxes. It does not change the canonical address immediately. Passkey-
// only reauthentication would be a separate, operation-bound protocol, not a bool.
func (service *Service) RequestChange(ctx context.Context, session [32]byte, currentPassword, newEmail string) error {
if len(currentPassword) > 1024 {
return ErrInvalid
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if !auth.VerifyPassword(subject.PasswordHash, currentPassword) {
return auth.ErrInvalidCredentials
}
newEmail, err = NormalizeEmail(newEmail)
if err != nil {
return err
}
current, err := NormalizeEmail(subject.Email)
if err != nil || newEmail == current {
return ErrInvalid
}
return service.issue(ctx, subject, session, Change, newEmail, now)
}
// RequestReset has the same result for unknown, unverified, inactive, malformed
// and account-rate-limited addresses. Applications must likewise keep responses
// generic, rate-limit by IP and avoid response timing tied to actual SMTP work.
func (service *Service) RequestReset(ctx context.Context, email string) error {
email, err := NormalizeEmail(email)
if err != nil {
return nil
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.ResetSubject(ctx, email, now)
if errors.Is(err, ErrUnavailable) {
return nil
}
if err != nil {
return err
}
err = service.issue(ctx, subject, [32]byte{}, Reset, "", now)
if errors.Is(err, ErrLimited) || errors.Is(err, ErrUnavailable) {
return nil
}
return err
}
func (service *Service) issue(ctx context.Context, subject Subject, session [32]byte, purpose Purpose, newEmail string, now time.Time) error {
email, err := NormalizeEmail(subject.Email)
if err != nil {
return ErrUnavailable
}
id, err := service.random(18)
if err != nil {
return err
}
token, err := service.random(32)
if err != nil {
return err
}
digest, _ := TokenDigest(token)
request := Request{ID: "request_" + id, UserID: subject.UserID, Email: email, NewEmail: newEmail, Purpose: purpose, Revision: subject.Revision, CredentialDigest: sha256.Sum256([]byte(subject.PasswordHash)), SessionDigest: session, NewDigest: digest, CreatedAt: now, ExpiresAt: now.Add(Lifetime)}
kind, target := string(purpose), email
if purpose == Change {
kind, target = "change-new", newEmail
}
message, err := service.message(kind, target, token, now)
if err != nil {
return err
}
messages := []mail.Message{message}
if purpose == Change {
oldToken, err := service.random(32)
if err != nil {
return err
}
request.OldDigest, _ = TokenDigest(oldToken)
oldMessage, err := service.message("change-old", email, oldToken, now)
if err != nil {
return err
}
messages = append(messages, oldMessage)
}
audit, err := service.audit(string(purpose)+".request", subject.UserID, now)
if err != nil {
return err
}
return service.repository.Issue(ctx, request, messages, audit)
}
// Inspect is read-only and intentionally returns no account or address details.
func (service *Service) Inspect(ctx context.Context, token string) (Purpose, error) {
digest, err := TokenDigest(token)
if err != nil {
return "", err
}
pending, err := service.repository.Pending(ctx, digest, service.config.Now().UTC())
if err != nil {
return "", err
}
return pending.Request.Purpose, nil
}
// Confirm consumes a verification/change token. false means the other mailbox
// still needs confirmation. It never issues a login or changes a password.
func (service *Service) Confirm(ctx context.Context, token string) (bool, error) {
return service.complete(ctx, token, "", false)
}
// ResetPassword requires a reset token and retains passkeys/recovery codes. The
// application must send the user through its normal sign-in and MFA afterwards.
func (service *Service) ResetPassword(ctx context.Context, token, password string) error {
if err := auth.ValidatePassword(password); err != nil {
return err
}
_, err := service.complete(ctx, token, password, true)
return err
}
func (service *Service) complete(ctx context.Context, token, password string, reset bool) (bool, error) {
digest, err := TokenDigest(token)
if err != nil {
return false, err
}
now := service.config.Now().UTC().Truncate(time.Second)
pending, err := service.repository.Pending(ctx, digest, now)
if err != nil {
return false, err
}
if (pending.Request.Purpose == Reset) != reset {
return false, ErrUnavailable
}
var hash string
var notices []mail.Message
if reset {
if auth.VerifyPassword(pending.Subject.PasswordHash, password) {
return false, auth.ErrPasswordUnchanged
}
hash, err = auth.HashPasswordWithRandom(password, service.config.Random)
if err != nil {
return false, err
}
notice, err := service.message("password-changed", pending.Request.Email, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
} else if pending.Request.Purpose == Change {
for _, recipient := range []string{pending.Request.Email, pending.Request.NewEmail} {
notice, err := service.message("email-changed", recipient, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
}
}
audit, err := service.audit(string(pending.Request.Purpose)+".confirm", pending.Subject.UserID, now)
if err != nil {
return false, err
}
return service.repository.Complete(ctx, digest, pending.Request.ID, hash, notices, audit)
}
func DefaultComposer(intent MailIntent) (MessageContent, error) {
var title, text string
switch intent.Kind {
case "verify":
title, text = "Confirm your email", "Confirm that this is the email address you'd like to use for your account."
case "reset":
title, text = "Reset your password", "Someone requested a password reset for your account. If that was you, choose a new password using the link below."
case "change-new":
title, text = "Confirm your new email", "Confirm this address to continue your account email change. Your current mailbox also needs to approve the change."
case "change-old":
title, text = "Approve your email change", "Someone who confirmed your current password requested an account email change. Approve it only if you made this request; the new mailbox must confirm too."
case "password-changed":
title, text = "Your password was changed", "Your account password was reset. Existing sessions were signed out. Your passkeys and recovery codes were not removed."
case "email-changed":
title, text = "Your account email was changed", "Both mailboxes confirmed your account email change. Existing sessions were signed out. Your purchases and memberships still belong to the same account."
default:
return MessageContent{}, ErrInvalid
}
if intent.ActionURL != "" {
text += fmt.Sprintf("\n\n%s\n\nThis link expires at %s. Opening it alone does not change your account; the page asks you to confirm.", intent.ActionURL, intent.ExpiresAt.UTC().Format(time.RFC1123))
}
if intent.ActionURL != "" {
text += "\n\nIf you did not request this, do not confirm it."
} else {
text += "\n\nIf this was not you, please contact the site's support team promptly."
}
text += "\nVisit your account security page or contact support:\n" + intent.SecurityURL + "\n\n" + intent.SiteName + "\n"
return MessageContent{Subject: title + " — " + intent.SiteName, Text: text}, nil
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
package authmail
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
type unavailableRepository struct{}
func (unavailableRepository) OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) ResetSubject(context.Context, string, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error {
return ErrUnavailable
}
func (unavailableRepository) Pending(context.Context, [32]byte, time.Time) (Pending, error) {
return Pending{}, ErrUnavailable
}
func (unavailableRepository) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) {
return false, ErrUnavailable
}
func testConfig() Config {
return Config{Origin: "https://example.test", SiteName: "Example", ConfirmPath: "/account/email/confirm/", ResetPath: "/password/reset/", SecurityPath: "/account/security/"}
}
func TestTrustedOriginAndRouteValidation(t *testing.T) {
for _, origin := range []string{"http://example.test", "https://user:password@example.test", "https://example.test/path", "https://example.test/?query=1", "https://example.test/#fragment", "javascript:alert(1)", "//example.test", "https://"} {
config := testConfig()
config.Origin = origin
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid origin accepted: %q", origin)
}
}
for _, route := range []string{"//evil.test/path", "/path?token=1", "/path#fragment", "/a/../b", "relative/path", "/bad\\path", "/bad\npath", "/%2f%2fevil.test"} {
config := testConfig()
config.ConfirmPath = route
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid route accepted: %q", route)
}
}
if _, err := New(nil, testConfig()); !errors.Is(err, ErrInvalid) {
t.Fatal("nil repository")
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
if got := service.link("/password/reset/", "a+/&b"); got != "https://example.test/password/reset/?token=a%2B%2F%26b" {
t.Fatalf("link encoding: %s", got)
}
fragmentConfig := testConfig()
fragmentConfig.TokenInFragment = true
fragmentService, err := New(unavailableRepository{}, fragmentConfig)
if err != nil {
t.Fatal(err)
}
if got := fragmentService.link("/password/reset/", "fixture-token"); got != "https://example.test/password/reset/#token=fixture-token" {
t.Fatal("fragment mode exposed token in request target")
}
config := testConfig()
config.SiteName = "Name\nInjected: header"
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatal("site name injection")
}
}
func TestMailboxAndTokenValidation(t *testing.T) {
if got, err := NormalizeEmail(" READER@Example.Test "); err != nil || got != "reader@example.test" {
t.Fatalf("normalization %q %v", got, err)
}
for _, email := range []string{"Name <reader@example.test>", "reader@example.test\nBcc: other@example.test", "ü@example.test", "a,b@example.test", "missing-at", strings.Repeat("x", 255) + "@example.test"} {
if _, err := NormalizeEmail(email); err == nil {
t.Fatalf("invalid address accepted: %q", email)
}
}
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
first, err := TokenDigest(token)
if err != nil || first == ([32]byte{}) {
t.Fatal("valid token rejected")
}
for _, value := range []string{"", token + "=", token[:42], strings.Repeat("a", 200), " " + token} {
if _, err := TokenDigest(value); !errors.Is(err, ErrUnavailable) {
t.Fatal("malformed token accepted")
}
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
for _, value := range []string{"not-an-email", "unknown@example.test"} {
if err := service.RequestReset(t.Context(), value); err != nil {
t.Fatal("reset enumeration", err)
}
}
if _, err = service.Inspect(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
}
func TestDefaultAndCustomCopyDoesNotControlEnvelope(t *testing.T) {
config := testConfig()
config.Compose = func(intent MailIntent) (MessageContent, error) {
return MessageContent{Subject: "Custom subject", Text: "Custom body: " + intent.ActionURL}, nil
}
service, err := New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
message, err := service.message("verify", "recipient@example.test", "token", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if message.To != "recipient@example.test" || message.Subject != "Custom subject" || !strings.Contains(message.Text, "https://example.test/account/email/confirm/?token=token") {
t.Fatal("custom copy bypassed fixed envelope/origin")
}
config.Compose = func(MailIntent) (MessageContent, error) {
return MessageContent{Subject: "bad\nBcc: another@example.test", Text: "body"}, nil
}
service, err = New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
if _, err = service.message("verify", "recipient@example.test", "token", time.Now().UTC()); !errors.Is(err, ErrInvalid) {
t.Fatal("custom header injection")
}
for _, kind := range []string{"verify", "reset", "change-new", "change-old", "password-changed", "email-changed"} {
content, err := DefaultComposer(MailIntent{Kind: kind, SiteName: "Example", SecurityURL: "https://example.test/security/", ActionURL: "https://example.test/confirm/", ExpiresAt: time.Now().UTC()})
if err != nil || content.Subject == "" || content.Text == "" {
t.Fatal("missing copy", kind)
}
}
if _, err = DefaultComposer(MailIntent{Kind: "unknown"}); !errors.Is(err, ErrInvalid) {
t.Fatal("unknown intent")
}
for _, value := range []any{Subject{Email: "private@example.test", PasswordHash: "secret-hash"}, Request{Email: "private@example.test"}, Pending{Subject: Subject{Email: "private@example.test"}}} {
if strings.Contains(fmt.Sprintf("%v %#v", value, value), "private@example.test") {
t.Fatal("sensitive debug output")
}
}
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
package authrecovery_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/organizations"
)
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
random := &counterReader{}
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
if err != nil {
t.Fatal(err)
}
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
if err != nil {
t.Fatal(err)
}
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
Permissions: map[string]string{"account.recover": "Recover an organization member"},
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
existingID := bytes.Repeat([]byte{7}, 32)
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
if err != nil {
t.Fatal(err)
}
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
t.Fatal(err)
}
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
if err != nil {
t.Fatal(err)
}
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
t.Fatalf("non-owner assisted recovery err=%v", err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
t.Fatalf("denied recovery changed password: %v", err)
}
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
if err != nil || loaded.ID != target.ID || grant == "" {
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
}
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived assisted recovery issue: %v", err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old password survived assisted recovery issue: %v", err)
}
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
if err != nil || len(credentials) != 0 {
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
}
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old recovery path survived issue: %v", err)
}
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
}
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
t.Fatalf("result=%+v err=%v", result, err)
}
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
t.Fatalf("replacement password unavailable: %v", err)
}
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
}
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
t.Fatalf("assisted grant replay err=%v", err)
}
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
}
audits, err := accessService.Audit(t.Context(), home.ID, 20)
if err != nil {
t.Fatal(err)
}
seenIssue, seenComplete := false, false
for _, audit := range audits {
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
}
if !seenIssue || !seenComplete {
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
}
}
+476
View File
@@ -0,0 +1,476 @@
// 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/access"
"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")
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
)
type Grant struct {
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
}
// AssistedGrant is the digest-only authority created by an organization
// owner after a human recovery review. The plaintext token is returned once
// to the caller and never persisted or audited.
type AssistedGrant struct {
Digest [32]byte
OrganizationID, UserID string
IssuedByUserID string
CreatedAt, ExpiresAt time.Time
}
// AssistedIssue binds an owner-reviewed recovery to one organization member.
// Reason is deliberately bounded and must not contain credential material.
type AssistedIssue struct {
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
}
// AssistedRepository provides the two transactional boundaries for delegated
// recovery. Issuance invalidates all existing account authenticators and
// sessions while recording both identity and organization-visible audits.
// Completion consumes the grant exactly once and installs the replacement
// password, passkey, and recovery-code set atomically.
type AssistedRepository interface {
Repository
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
}
// AssistedCompletion contains only the password hash, public passkey
// credential, digest-only recovery codes, and secret-free audit material.
type AssistedCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
PasswordHash string
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
type Passkeys interface {
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
// PasskeyCompletion contains the public credential, digest-only replacement
// codes, and secret-free audits committed after a recovery ceremony.
type PasskeyCompletion struct {
GrantDigest [32]byte
Credential authwebauthn.Credential
RecoveryDigests [][32]byte
PasskeyAudit auth.AuditEvent
RecoveryAudit auth.AuditEvent
CompletedAt time.Time
}
// PasskeyFinishResult returns the verified credential and the new plaintext
// recovery codes. Applications must display the codes once and retain none.
type PasskeyFinishResult struct {
Credential authwebauthn.Credential
RecoveryCodes []string
}
type PasswordVerifier interface {
VerifyPassword(context.Context, string, string) (auth.User, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
AssistedGrantLifetime time.Duration
OwnerRole string
Passkeys Passkeys
}
type Service struct {
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
assistedTTL time.Duration
ownerRole string
passkeys Passkeys
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
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.AssistedGrantLifetime == 0 {
options.AssistedGrantLifetime = 15 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
return nil, errors.New("authrecovery: invalid recovery policy")
}
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
}
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
// token. The repository immediately invalidates the target's previous
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
// reviewed recovery cannot race an older authenticator.
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
repository, ok := service.repository.(AssistedRepository)
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
input.RequestID = strings.TrimSpace(input.RequestID)
input.Reason = strings.TrimSpace(input.Reason)
if !ok || service.passkeys == nil || service.ownerRole == "" {
return auth.User{}, "", ErrPasskeyUnavailable
}
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
}
raw, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
authAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
if err != nil {
return auth.User{}, "", err
}
return user, raw, nil
}
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
// session. The grant remains reusable for ceremony restart until completion or
// expiry; only completion consumes it.
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return authwebauthn.BeginResult{}, ErrAssistedNotFound
}
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
}
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
// that installs every replacement credential and both audit trails. No normal
// session is issued; the recovered user signs in with the new credentials.
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
repository, ok := service.repository.(AssistedRepository)
if !ok || service.passkeys == nil {
return PasskeyFinishResult{}, ErrPasskeyUnavailable
}
digest, err := grantDigest(rawGrant)
if err != nil {
return PasskeyFinishResult{}, ErrAssistedNotFound
}
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
if err != nil {
return PasskeyFinishResult{}, err
}
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
if err != nil {
return PasskeyFinishResult{}, err
}
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return PasskeyFinishResult{}, err
}
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
if verified.UserID != user.ID {
return errors.New("authrecovery: assisted recovery identity mismatch")
}
completedAt := service.now().UTC()
recoveryAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
accessAuditID, auditErr := token(service.random, 18)
if auditErr != nil {
return auditErr
}
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
})
if err != nil {
return PasskeyFinishResult{}, err
}
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
}
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
// 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
}
func opaqueID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
func safeRole(value string) bool {
if len(value) < 1 || len(value) > 96 {
return false
}
for _, character := range value {
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
continue
}
return false
}
return true
}
+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
}
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"slices"
"time"
"gamertan.com/web/access"
)
func (store *Store) SeedAccessPolicy(ctx context.Context, policy access.Policy) error {
if err := policy.Validate(); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for name, description := range policy.Roles {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_roles(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
return err
}
}
for name, description := range policy.Permissions {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_permissions(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
return err
}
}
for role, permissions := range policy.Grants {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_access_role_permissions WHERE role_name=?`, role); err != nil {
return err
}
for _, permission := range permissions {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_role_permissions(role_name,permission_name) VALUES(?,?)`, role, permission); err != nil {
return err
}
}
}
return tx.Commit()
}
func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
if !opaqueID(binding.ID) || (binding.SubjectKind != access.User && binding.SubjectKind != access.Team) || !opaqueID(binding.SubjectID) || !safeName(binding.Role) || binding.Scope.Validate() != nil || !opaqueID(binding.GrantedBy) || binding.GrantedAt.IsZero() {
return errors.New("authsqlite: invalid access binding")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var exists int
query := `SELECT COUNT(*) FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`
if binding.SubjectKind == access.Team {
query = `SELECT COUNT(*) FROM gwf_teams t JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active' WHERE t.organization_id=? AND t.id=? AND t.status='active'`
}
if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil {
return err
}
if exists != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 {
if err != nil {
return err
}
return errors.New("authsqlite: grantor is not active in organization")
}
scopeQuery, arguments := `SELECT 1`, []any{}
switch {
case binding.Scope.ServiceID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_application_services WHERE id=? AND environment_id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.ServiceID, binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
case binding.Scope.EnvironmentID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
case binding.Scope.ProjectID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_projects WHERE id=? AND organization_id=?`, []any{binding.Scope.ProjectID, binding.Scope.OrganizationID}
}
if err = tx.QueryRowContext(ctx, scopeQuery, arguments...).Scan(&exists); err != nil {
return err
}
if exists != 1 {
return errors.New("authsqlite: access scope does not exist")
}
if _, 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) VALUES(?,?,?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,?)`, binding.ID, binding.Scope.OrganizationID, binding.SubjectKind, binding.SubjectID, binding.Role, binding.Scope.ProjectID, binding.Scope.EnvironmentID, binding.Scope.ServiceID, binding.GrantedBy, binding.GrantedAt.Unix()); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) Revoke(ctx context.Context, bindingID, actorUserID string, when time.Time) error {
if !opaqueID(bindingID) || !opaqueID(actorUserID) || when.IsZero() {
return errors.New("authsqlite: invalid access revocation")
}
result, err := store.db.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE id=? AND revoked_at IS NULL`, actorUserID, when.Unix(), bindingID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: access binding not found")
}
return nil
}
func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userID string) ([]access.Binding, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid access query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active'
WHERE b.organization_id=? AND b.revoked_at IS NULL
AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active')
AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id AND t.status='active')))
ORDER BY b.id`, organizationID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.Binding
for rows.Next() {
var binding access.Binding
var project, environment, service sql.NullString
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectKind, &binding.SubjectID, &binding.Role, &project, &environment, &service, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.Scope = access.Scope{OrganizationID: organizationID, ProjectID: project.String, EnvironmentID: environment.String, ServiceID: service.String}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization binding query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
WHERE b.organization_id=? AND b.subject_kind='user'
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL
ORDER BY b.subject_id,b.role_name,b.id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]access.Binding, 0)
for rows.Next() {
var binding access.Binding
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.SubjectKind = access.User
binding.Scope = access.Scope{OrganizationID: organizationID}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
}
func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error {
return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true)
}
func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error {
if len(replacements) < 1 || len(replacements) > 16 {
return errors.New("authsqlite: invalid organization role set")
}
replacement := replacements[0]
roles := make([]string, 0, len(replacements))
ids := make(map[string]bool, len(replacements))
for _, value := range replacements {
if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) {
return errors.New("authsqlite: invalid organization role set")
}
roles = append(roles, value.Role)
ids[value.ID] = true
}
slices.Sort(roles)
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the SQLite write lock before reading the optimistic binding set.
// This serializes competing role replacements so the loser observes the
// committed binding IDs and returns ErrRoleChangeConflict instead of an
// ambiguous busy-snapshot error.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: role grantor is not active in organization")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
if active != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
if err != nil {
return err
}
var currentIDs []string
var currentRoles []string
for rows.Next() {
var id, role string
if err = rows.Scan(&id, &role); err != nil {
rows.Close()
return err
}
currentIDs = append(currentIDs, id)
currentRoles = append(currentRoles, role)
}
if err = rows.Err(); err != nil {
rows.Close()
return err
}
if err = rows.Close(); err != nil {
return err
}
if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict
}
slices.Sort(currentRoles)
if slices.Equal(currentRoles, roles) {
return access.ErrRoleUnchanged
}
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return access.ErrOwnerAuthority
}
}
if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
return err
}
if otherOwners == 0 {
return access.ErrLastOwner
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=?
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
return err
}
for _, value := range replacements {
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=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
return false
}
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
return false
}
if len(expected) > 16 || !slices.IsSorted(expected) {
return false
}
for index, id := range expected {
if !opaqueID(id) || index > 0 && expected[index-1] == id {
return false
}
}
return true
}
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organizations o JOIN gwf_organization_memberships m ON m.organization_id=o.id WHERE o.id=? AND o.status='active' AND m.user_id=? AND m.status='active'`, grant.OrganizationID, grant.UserID).Scan(&active); err != nil {
return err
}
if active != 1 {
return errors.New("authsqlite: break-glass principal is not active in organization")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID string, now time.Time) ([]access.BreakGlass, error) {
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
return nil, errors.New("authsqlite: invalid break-glass query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.permission_name,b.reason,b.created_at,b.expires_at FROM gwf_break_glass b JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active' JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.user_id AND m.status='active' WHERE b.organization_id=? AND b.user_id=? AND b.expires_at>? ORDER BY b.expires_at`, organizationID, userID, now.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.BreakGlass
for rows.Next() {
var grant access.BreakGlass
var created, expires int64
if err = rows.Scan(&grant.ID, &grant.Permission, &grant.Reason, &created, &expires); err != nil {
return nil, err
}
grant.OrganizationID, grant.UserID = organizationID, userID
grant.CreatedAt, grant.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
result = append(result, grant)
}
return result, rows.Err()
}
func (store *Store) AppendAccessAudit(ctx context.Context, audit access.AuditEvent) error {
if !validAccessAudit(audit) {
return errors.New("authsqlite: invalid access audit")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
return err
}
func (store *Store) AccessAudit(ctx context.Context, organizationID string, limit int) ([]access.AuditEvent, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("authsqlite: invalid access audit query")
}
rows, err := store.db.QueryContext(ctx, `SELECT id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at FROM gwf_access_audit_events WHERE organization_id=? ORDER BY created_at DESC,id DESC LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.AuditEvent
for rows.Next() {
var event access.AuditEvent
var requestID sql.NullString
var created int64
if err = rows.Scan(&event.ID, &event.ActorUserID, &event.Action, &event.ResourceType, &event.ResourceID, &requestID, &event.Summary, &created); err != nil {
return nil, err
}
event.OrganizationID = organizationID
event.RequestID = requestID.String
event.CreatedAt = time.Unix(created, 0).UTC()
result = append(result, event)
}
return result, rows.Err()
}
func appendAccessAudit(ctx context.Context, tx *sql.Tx, audit access.AuditEvent) error {
_, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
return err
}
func validBreakGlass(grant access.BreakGlass) bool {
return opaqueID(grant.ID) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && safeName(grant.Permission) && text(grant.Reason, 1024, false) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) <= time.Hour
}
func validAccessAudit(audit access.AuditEvent) bool {
return opaqueID(audit.ID) && opaqueID(audit.OrganizationID) && opaqueID(audit.ActorUserID) && safeName(audit.Action) && safeName(audit.ResourceType) && text(audit.ResourceID, 256, false) && text(audit.RequestID, 128, true) && text(audit.Summary, 1024, true) && !audit.CreatedAt.IsZero()
}
+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)
}
}
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
// Take the SQLite write lock before checking owner authority so a role or
// membership mutation cannot race the reviewed recovery decision.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)
AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
if err != nil {
return auth.User{}, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return auth.User{}, authrecovery.ErrAssistedDenied
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authrecovery.ErrAssistedDenied
}
if err != nil {
return auth.User{}, err
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
return auth.User{}, err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, authAudit); err != nil {
return auth.User{}, err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
var grant authrecovery.AssistedGrant
var user auth.User
var created, expires, userCreated, userUpdated int64
var passwordChangeRequired, registrationPending int
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
FROM gwf_assisted_recovery_grants g
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &userCreated, &userUpdated)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
}
if err != nil {
return authrecovery.AssistedGrant{}, auth.User{}, err
}
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
return grant, user, nil
}
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
credential := completion.Credential
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
return errors.New("authsqlite: invalid assisted recovery completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, digest := range completion.RecoveryDigests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid assisted recovery-code digest")
}
if _, duplicate := seen[digest]; duplicate {
return errors.New("authsqlite: duplicate assisted recovery-code digest")
}
seen[digest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var userID, organizationID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
if errors.Is(err, sql.ErrNoRows) {
return authrecovery.ErrAssistedNotFound
}
if err != nil {
return err
}
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
return errors.New("authsqlite: assisted recovery identity mismatch")
}
var active int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
return err
}
if active != 1 {
return auth.ErrInactiveUser
}
for _, statement := range []string{
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
} {
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
for _, digest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/bootstrap"
)
// CreateInitialOwner commits the root-local bootstrap across identity,
// enrollment, organization, membership, owner access, and all audit records.
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
user := setup.User
organization := setup.Organization
membership := setup.Membership
binding := setup.OwnerBinding
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
!validOwnerBinding(binding, organization.ID, user.ID) ||
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
return errors.New("authsqlite: invalid initial owner bootstrap")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ bootstrap.Repository = (*Store)(nil)
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/bootstrap"
)
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
user, err := store.UserByID(t.Context(), created.User.ID)
if err != nil || user.Email != "cole@example.test" {
t.Fatalf("user=%+v err=%v", user, err)
}
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
if err != nil || organization.Personal || organization.Slug != "gamertan" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
t.Fatalf("memberships=%+v err=%v", memberships, err)
}
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
passkeyService := testBootstrapPasskeyService(t, store, now)
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
if err != nil || begin.CeremonyToken == "" {
t.Fatalf("begin=%+v err=%v", begin, err)
}
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("enrollment replay err=%v", err)
}
var authAudits, accessAudits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
t.Fatal(err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
t.Fatal(err)
}
if authAudits != 1 || accessAudits != 2 {
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
}
}
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
t.Fatal("bootstrap succeeded without seeded role")
}
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
var count int
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
}
}
}
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
t.Helper()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return service
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
var _ auth.UserDirectoryRepository = (*Store)(nil)
var _ organizations.DirectoryRepository = (*Store)(nil)
// UserDirectory is an administrative read; the adapter cannot infer application
// authorization. Search covers ID, username, email and display name. SQLite LIKE
// folds ASCII case; non-ASCII display-name text matches with its original case.
func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at
FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1)
if err != nil {
return auth.UserDirectoryPage{}, err
}
defer rows.Close()
page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)}
for rows.Next() {
user, err := scanPasskeyUser(rows)
if err != nil {
return auth.UserDirectoryPage{}, err
}
page.Users = append(page.Users, user)
}
if err = rows.Err(); err != nil {
return auth.UserDirectoryPage{}, err
}
if len(page.Users) > limit {
page.Users = page.Users[:limit]
page.NextID = page.Users[limit-1].ID
}
return page, nil
}
// OrganizationDirectory reads all personal/business and active/archived records.
// It does not join membership, grant access, or choose a merchant. Search covers
// exact ID and literal slug/name text using SQLite's ASCII case folding.
func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) {
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
if !valid {
return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery
}
rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at
FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1)
if err != nil {
return organizations.DirectoryPage{}, err
}
defer rows.Close()
page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)}
for rows.Next() {
var value organizations.Organization
var created, updated int64
if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil {
return organizations.DirectoryPage{}, err
}
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
page.Organizations = append(page.Organizations, value)
}
if err = rows.Err(); err != nil {
return organizations.DirectoryPage{}, err
}
if len(page.Organizations) > limit {
page.Organizations = page.Organizations[:limit]
page.NextID = page.Organizations[limit-1].ID
}
return page, nil
}
func directoryQuery(search, after string, limit int) (string, int, bool) {
if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 {
return "", 0, false
}
if limit == 0 {
limit = 50
}
// Wildcards and the escape character are literal user text, never operators.
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%"
return pattern, limit, true
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
func TestInstanceDirectoriesAreBoundedCredentialFreeAndIndependentOfMembership(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := t.Context()
for index := 0; index < 205; index++ {
id := fmt.Sprintf("record-%03d", index)
status := []string{"active", "suspended", "disabled"}[index%3]
_, err = store.db.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(?,?,?,?,?,?,?,?,?,1,1)`, id, id, id, id+"@example.test", id+"@example.test", "Person "+id, status, index%2, index%5 == 0)
if err != nil {
t.Fatal(err)
}
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,1,1)`, id, id, "Business "+id, index%2, []string{"active", "archived"}[index%2])
if err != nil {
t.Fatal(err)
}
}
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{})
if err != nil || len(users.Users) != 50 || users.NextID != "record-049" {
t.Fatalf("default users: %d %q %v", len(users.Users), users.NextID, err)
}
if !users.Users[0].RegistrationPending || users.Users[1].Status != "suspended" || !users.Users[1].PasswordChangeRequired || users.Users[2].Status != "disabled" {
t.Fatal("administrative account states were hidden")
}
encoded, _ := json.Marshal(users)
for _, secret := range []string{"password_hash", "Session", "Digest", "Credential", "Recovery"} {
if strings.Contains(string(encoded), secret) {
t.Fatalf("directory leaked credential field %s", secret)
}
}
for _, size := range []int{1, 50, 200} {
userAfter, orgAfter, count := "", "", 0
for {
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: userAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{AfterID: orgAfter, Limit: size})
if err != nil {
t.Fatal(err)
}
if len(users.Users) != len(orgs.Organizations) || len(users.Users) > size {
t.Fatal("invalid page bound")
}
for index, user := range users.Users {
want := fmt.Sprintf("record-%03d", count)
if user.ID != want || orgs.Organizations[index].ID != want {
t.Fatalf("pagination skipped/duplicated %s", want)
}
count++
}
if users.NextID == "" || orgs.NextID == "" {
if users.NextID != orgs.NextID || count != 205 {
t.Fatalf("early end: %d", count)
}
break
}
userAfter, orgAfter = users.NextID, orgs.NextID
}
}
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{Limit: 2})
if err != nil || orgs.Organizations[0].Personal || !orgs.Organizations[1].Personal || orgs.Organizations[1].Status != "archived" {
t.Fatal("personal/archived organizations omitted")
}
// A display-name change cannot move a record behind a stable-ID cursor.
if _, err = store.db.Exec(`UPDATE gwf_users SET display_name='AAA' WHERE id='record-050'`); err != nil {
t.Fatal(err)
}
next, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: "record-049", Limit: 1})
if err != nil || next.Users[0].ID != "record-050" {
t.Fatal("name change disturbed cursor")
}
}
func TestInstanceDirectoryLiteralSearchValidationAndCancellation(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
_, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('person-001','Alice','alice','Alice@example.test','alice@example.test','Élodie 50%_\ works','active',1,1)`)
if err != nil {
t.Fatal(err)
}
_, err = store.db.Exec(`INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES('company-001','alice-company','Élodie 50%_\ works',0,'active',1,1,1)`)
if err != nil {
t.Fatal(err)
}
for _, search := range []string{"", " ALICE ", "example.test", "person-001", "Élodie", `50%_\`} {
page, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || len(page.Users) != 1 || page.NextID != "" {
t.Errorf("user literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"", "ALICE", "company-001", "Élodie", `50%_\`} {
page, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || len(page.Organizations) != 1 || page.NextID != "" {
t.Errorf("organization literal search %q: %#v %v", search, page, err)
}
}
for _, search := range []string{"absent", "%' OR 1=1 --", "%_%", "\\_%"} {
users, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
if err != nil || users.Users == nil || len(users.Users) != 0 {
t.Errorf("nonliteral user search %q", search)
}
orgs, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
if err != nil || orgs.Organizations == nil || len(orgs.Organizations) != 0 {
t.Errorf("nonliteral org search %q", search)
}
}
for _, query := range []auth.UserDirectoryQuery{{Search: strings.Repeat("a", 129)}, {Search: "bad\x00value"}, {Search: "bad\nvalue"}, {Search: "\xff"}, {AfterID: "bad/id"}, {AfterID: strings.Repeat("a", 129)}, {Limit: -1}, {Limit: 201}} {
if _, err := store.UserDirectory(t.Context(), query); !errors.Is(err, auth.ErrDirectoryQuery) {
t.Errorf("invalid user query accepted: %#v %v", query, err)
}
if _, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: query.Search, AfterID: query.AfterID, Limit: query.Limit}); !errors.Is(err, organizations.ErrDirectoryQuery) {
t.Errorf("invalid org query accepted: %#v %v", query, err)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err = store.UserDirectory(ctx, auth.UserDirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("user cancellation: %v", err)
}
if _, err = store.OrganizationDirectory(ctx, organizations.DirectoryQuery{}); !errors.Is(err, context.Canceled) {
t.Fatalf("organization cancellation: %v", err)
}
}
+415
View File
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
"modernc.org/sqlite"
)
// MailRepository is optional. Construction opens no new connection and performs
// no migration. The outbox shares this store's DB so account/audit/mail commits
// are atomic. Applications must check RequireMailSchema before enabling routes.
type MailRepository struct {
store *Store
queue *mailsqlite.Queue
now func() time.Time
}
func (store *Store) AccountMail(options mailsqlite.Options) (*MailRepository, *mailsqlite.Queue, error) {
if options.Now == nil {
options.Now = time.Now
}
queue, err := mailsqlite.New(store.db, options)
if err != nil {
return nil, nil, err
}
return &MailRepository{store: store, queue: queue, now: options.Now}, queue, nil
}
const MailSchemaVersion = 1
func (store *Store) RequireMailSchema(ctx context.Context) error {
var version int
if err := store.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return errors.New("authsqlite: explicit account mail migration required")
}
if version != MailSchemaVersion {
return errors.New("authsqlite: incompatible account mail schema")
}
return nil
}
// MigrateMail is an explicit operator migration, separate from schema 11. It
// creates no verified identities for existing users and touches no commerce data.
func (store *Store) MigrateMail(ctx context.Context) error {
if err := store.RequireCurrentSchema(ctx); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS gwf_account_mail_migrations(version INTEGER PRIMARY KEY,applied_at INTEGER NOT NULL)`); err != nil {
return err
}
var version int
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return err
}
if version > MailSchemaVersion {
return errors.New("authsqlite: newer account mail schema")
}
if version == MailSchemaVersion {
return tx.Commit()
}
if err = mailsqlite.CreateSchema(ctx, tx); err != nil {
return err
}
for _, statement := range []string{
`CREATE TABLE gwf_verified_emails(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,email_normalized TEXT NOT NULL,verified_at INTEGER NOT NULL)`,
`CREATE TABLE gwf_account_mail_requests(id TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE,purpose TEXT NOT NULL CHECK(purpose IN ('verify','change','reset')),email TEXT NOT NULL,new_email TEXT NOT NULL,profile_revision INTEGER NOT NULL,credential_digest BLOB NOT NULL CHECK(length(credential_digest)=32),session_digest BLOB NOT NULL CHECK(length(session_digest)=32),new_digest BLOB NOT NULL UNIQUE CHECK(length(new_digest)=32),old_digest BLOB UNIQUE,new_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(new_confirmed IN (0,1)),old_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(old_confirmed IN (0,1)),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL CHECK(expires_at>created_at),PRIMARY KEY(user_id,purpose),CHECK(old_digest IS NULL OR length(old_digest)=32))`,
`CREATE INDEX gwf_account_mail_requests_expiry ON gwf_account_mail_requests(expires_at)`,
`CREATE TABLE gwf_account_mail_limits(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,window_started INTEGER NOT NULL,request_count INTEGER NOT NULL,last_requested INTEGER NOT NULL)`,
} {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_migrations(version,applied_at) VALUES(?,?)`, MailSchemaVersion, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
const mailSubjectSelect = `SELECT u.id,u.email,c.password_hash,u.profile_revision,EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)
FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.status='active' AND u.registration_pending=0`
func scanMailSubject(row interface{ Scan(...any) error }) (authmail.Subject, error) {
var subject authmail.Subject
err := row.Scan(&subject.UserID, &subject.Email, &subject.PasswordHash, &subject.Revision, &subject.Verified)
if errors.Is(err, sql.ErrNoRows) {
return subject, authmail.ErrUnavailable
}
return subject, err
}
func (repo *MailRepository) OwnSubject(ctx context.Context, session [32]byte, now time.Time) (authmail.Subject, error) {
if zeroDigest(session) || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.password_change_required=0 AND EXISTS(SELECT 1 FROM gwf_auth_sessions s WHERE s.user_id=u.id AND s.token_hash=? AND s.expires_at>?)`, session[:], now.Unix()))
}
func (repo *MailRepository) ResetSubject(ctx context.Context, email string, now time.Time) (authmail.Subject, error) {
value, err := authmail.NormalizeEmail(email)
if err != nil || value != email || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.email_normalized=? AND EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)`, email))
}
func mailAuditValid(audit auth.AuditEvent, request authmail.Request, suffix string) bool {
actor := request.UserID
if request.Purpose == authmail.Reset {
actor = ""
}
return validAuditEvent(audit) && audit.ActorUserID == actor && audit.Action == "auth.mail."+string(request.Purpose)+suffix && audit.ResourceType == "user" && audit.ResourceID == request.UserID
}
func mailRequestValid(request authmail.Request) bool {
email, err := authmail.NormalizeEmail(request.Email)
if err != nil || email != request.Email || !opaqueID(request.ID) || !opaqueID(request.UserID) || request.Revision < 1 || zeroDigest(request.NewDigest) || zeroDigest(request.CredentialDigest) || request.CreatedAt.IsZero() || request.ExpiresAt.Sub(request.CreatedAt) != authmail.Lifetime {
return false
}
if request.Purpose == authmail.Change {
value, err := authmail.NormalizeEmail(request.NewEmail)
return err == nil && value == request.NewEmail && value != email && !zeroDigest(request.OldDigest) && request.OldDigest != request.NewDigest && !zeroDigest(request.SessionDigest)
}
return request.NewEmail == "" && zeroDigest(request.OldDigest) && (request.Purpose == authmail.Verify && !zeroDigest(request.SessionDigest) || request.Purpose == authmail.Reset && zeroDigest(request.SessionDigest))
}
func mailSubjectMatches(subject authmail.Subject, request authmail.Request) bool {
return subject.UserID == request.UserID && normalize(subject.Email) == request.Email && subject.Revision == request.Revision && sha256.Sum256([]byte(subject.PasswordHash)) == request.CredentialDigest && (request.Purpose != authmail.Reset || subject.Verified)
}
func mailSessionCurrent(ctx context.Context, tx *sql.Tx, request authmail.Request, now time.Time) error {
if request.Purpose == authmail.Reset {
return nil
}
var valid int
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return err
}
if valid != 1 {
return authmail.ErrUnavailable
}
return nil
}
func (repo *MailRepository) Issue(ctx context.Context, request authmail.Request, messages []mail.Message, audit auth.AuditEvent) error {
if !mailRequestValid(request) || !mailAuditValid(audit, request, ".request") || !audit.CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
target := request.Email
if request.Purpose == authmail.Change {
target = request.NewEmail
}
if len(messages) != 1 && request.Purpose != authmail.Change || request.Purpose == authmail.Change && len(messages) != 2 {
return authmail.ErrInvalid
}
if messages[0].To != target || messages[0].Validate() != nil || !messages[0].CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
if len(messages) == 2 && (messages[1].To != request.Email || messages[1].Validate() != nil || messages[0].ID == messages[1].ID || !messages[1].CreatedAt.Equal(request.CreatedAt)) {
return authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the writer lock before all identity, uniqueness and rate checks.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET updated_at=updated_at WHERE id=?`, request.UserID); err != nil {
return err
}
current := repo.now().UTC()
if !request.ExpiresAt.After(current) || request.CreatedAt.After(current.Add(time.Minute)) || request.CreatedAt.Before(current.Add(-time.Minute)) {
return authmail.ErrUnavailable
}
subject, err := scanMailSubject(tx.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return err
}
if !mailSubjectMatches(subject, request) {
return authmail.ErrUnavailable
}
if err = mailSessionCurrent(ctx, tx, request, current); err != nil {
return err
}
if request.Purpose == authmail.Verify && subject.Verified {
return authmail.ErrUnavailable
}
if request.Purpose == authmail.Change {
var collision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users WHERE email_normalized=?`, request.NewEmail).Scan(&collision); err != nil {
return err
}
if collision != 0 {
return authmail.ErrAddressUnavailable
}
}
var start, last int64
var count int
err = tx.QueryRowContext(ctx, `SELECT window_started,request_count,last_requested FROM gwf_account_mail_limits WHERE user_id=?`, request.UserID).Scan(&start, &count, &last)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
now := current.Unix()
if last > now-60 || start > now-3600 && count >= 5 {
return authmail.ErrLimited
}
if start <= now-3600 {
start, count = now, 0
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_limits(user_id,window_started,request_count,last_requested) VALUES(?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET window_started=excluded.window_started,request_count=excluded.request_count,last_requested=excluded.last_requested`, request.UserID, start, count+1, now); err != nil {
return err
}
var old []byte
if request.Purpose == authmail.Change {
old = request.OldDigest[:]
}
// Both token columns share one logical namespace. Detect even an entropy
// failure that collides with the other confirmation leg before inserting.
var tokenCollision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_account_mail_requests WHERE new_digest IN (?,?) OR old_digest IN (?,?)`, request.NewDigest[:], old, request.NewDigest[:], old).Scan(&tokenCollision); err != nil {
return err
}
if tokenCollision != 0 {
return authmail.ErrInvalid
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_requests(id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,purpose) DO UPDATE SET id=excluded.id,email=excluded.email,new_email=excluded.new_email,profile_revision=excluded.profile_revision,credential_digest=excluded.credential_digest,session_digest=excluded.session_digest,new_digest=excluded.new_digest,old_digest=excluded.old_digest,new_confirmed=0,old_confirmed=0,created_at=excluded.created_at,expires_at=excluded.expires_at`, request.ID, request.UserID, request.Purpose, request.Email, request.NewEmail, request.Revision, request.CredentialDigest[:], request.SessionDigest[:], request.NewDigest[:], old, request.CreatedAt.Unix(), request.ExpiresAt.Unix()); err != nil {
return err
}
for _, message := range messages {
if err = repo.queue.EnqueueTx(ctx, tx, message, request.ExpiresAt); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
type mailQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func pendingMail(ctx context.Context, query mailQuerier, digest [32]byte, now time.Time) (authmail.Pending, error) {
var pending authmail.Pending
if zeroDigest(digest) || now.IsZero() {
return pending, authmail.ErrUnavailable
}
request := &pending.Request
var credential, session, next, old []byte
var created, expires int64
err := query.QueryRowContext(ctx, `SELECT id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at FROM gwf_account_mail_requests WHERE expires_at>? AND ((new_digest=? AND new_confirmed=0) OR (old_digest=? AND old_confirmed=0))`, now.Unix(), digest[:], digest[:]).Scan(&request.ID, &request.UserID, &request.Purpose, &request.Email, &request.NewEmail, &request.Revision, &credential, &session, &next, &old, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return pending, authmail.ErrUnavailable
}
if err != nil {
return pending, err
}
copy(request.CredentialDigest[:], credential)
copy(request.SessionDigest[:], session)
copy(request.NewDigest[:], next)
copy(request.OldDigest[:], old)
request.CreatedAt, request.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if !mailRequestValid(*request) {
return pending, authmail.ErrUnavailable
}
pending.OldToken = digest == request.OldDigest
pending.Subject, err = scanMailSubject(query.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return pending, err
}
if !mailSubjectMatches(pending.Subject, *request) {
return pending, authmail.ErrUnavailable
}
if request.Purpose != authmail.Reset {
var valid int
err = query.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return pending, err
}
if valid != 1 {
return pending, authmail.ErrUnavailable
}
}
return pending, nil
}
func (repo *MailRepository) Pending(ctx context.Context, digest [32]byte, now time.Time) (authmail.Pending, error) {
return pendingMail(ctx, repo.store.db, digest, now)
}
func (repo *MailRepository) Complete(ctx context.Context, digest [32]byte, requestID, newHash string, notices []mail.Message, audit auth.AuditEvent) (bool, error) {
if zeroDigest(digest) || !opaqueID(requestID) || !validAuditEvent(audit) {
return false, authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET expires_at=expires_at WHERE id=?`, requestID); err != nil {
return false, err
}
// Re-read time after acquiring the writer lock, not before password hashing
// or a database wait. A just-expired token must not complete a mutation.
audit.CreatedAt = repo.now().UTC().Truncate(time.Second)
pending, err := pendingMail(ctx, tx, digest, audit.CreatedAt)
if err != nil {
return false, err
}
request := pending.Request
if request.ID != requestID || !mailAuditValid(audit, request, ".confirm") {
return false, authmail.ErrUnavailable
}
if request.Purpose == authmail.Reset {
if !strings.HasPrefix(newHash, "$argon2id$") || len(newHash) > 1024 || newHash == pending.Subject.PasswordHash || len(notices) != 1 || notices[0].To != request.Email {
return false, authmail.ErrInvalid
}
} else if newHash != "" || request.Purpose == authmail.Verify && len(notices) != 0 || request.Purpose == authmail.Change && (len(notices) != 2 || notices[0].To != request.Email || notices[1].To != request.NewEmail) {
return false, authmail.ErrInvalid
}
for _, message := range notices {
if message.Validate() != nil {
return false, authmail.ErrInvalid
}
}
column := "new_confirmed"
if pending.OldToken {
column = "old_confirmed"
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET `+column+`=1 WHERE id=?`, requestID); err != nil {
return false, err
}
if request.Purpose == authmail.Change {
var ready bool
if err = tx.QueryRowContext(ctx, `SELECT new_confirmed=1 AND old_confirmed=1 FROM gwf_account_mail_requests WHERE id=?`, requestID).Scan(&ready); err != nil {
return false, err
}
if !ready {
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return false, tx.Commit()
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET email=?,email_normalized=?,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) WHERE id=? AND profile_revision<9223372036854775807`, request.NewEmail, request.NewEmail, audit.CreatedAt.Unix(), request.UserID)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return false, authmail.ErrAddressUnavailable
}
return false, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return false, authmail.ErrUnavailable
}
// Invitations issued to the previous identity do not migrate to another
// mailbox/account. Existing memberships remain bound to immutable user ID.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE email_normalized=? AND used_at IS NULL AND revoked_at IS NULL`, audit.CreatedAt.Unix(), request.Email); err != nil {
return false, err
}
}
if request.Purpose != authmail.Reset {
email := request.Email
if request.Purpose == authmail.Change {
email = request.NewEmail
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_verified_emails(user_id,email_normalized,verified_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET email_normalized=excluded.email_normalized,verified_at=excluded.verified_at`, request.UserID, email, audit.CreatedAt.Unix()); err != nil {
return false, err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=?`, newHash, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=MAX(updated_at,?) WHERE id=?`, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
}
if request.Purpose != authmail.Verify {
// Keep enrolled passkeys and recovery-code digests. Revoke only sessions,
// in-flight ceremonies, enrollment/recovery grants and pending mail links.
for _, table := range []string{"gwf_auth_sessions", "gwf_passkey_ceremonies", "gwf_passkey_enrollment_tokens", "gwf_recovery_grants", "gwf_assisted_recovery_grants", "gwf_account_mail_requests"} {
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id=?`, request.UserID); err != nil {
return false, err
}
}
} else if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_account_mail_requests WHERE id=?`, requestID); err != nil {
return false, err
}
for _, message := range notices {
if err = repo.queue.EnqueueTx(ctx, tx, message, audit.CreatedAt.Add(24*time.Hour)); err != nil {
return false, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return true, tx.Commit()
}
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"net/url"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
)
const mailPassword = "correct horse battery staple"
var accountMailHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(mailPassword) })
type mailClock struct{ unix atomic.Int64 }
func (clock *mailClock) now() time.Time { return time.Unix(clock.unix.Load(), 0).UTC() }
func (clock *mailClock) advance(duration time.Duration) {
clock.unix.Add(int64(duration / time.Second))
}
type mailFixture struct {
store *Store
repo *MailRepository
queue *mailsqlite.Queue
service *authmail.Service
clock *mailClock
user auth.User
session auth.Session
path string
}
func mailConfig(clock *mailClock) authmail.Config {
return authmail.Config{Origin: "https://accounts.example.test", SiteName: "Example Site", ConfirmPath: "/account/email/confirm/", ResetPath: "/reset-password/", SecurityPath: "/account/security/", Now: clock.now}
}
func accountMailFixture(t *testing.T, capacity int) mailFixture {
t.Helper()
clock := &mailClock{}
clock.unix.Store(time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC).Unix())
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if err = store.RequireMailSchema(t.Context()); err == nil {
t.Fatal("mail schema silently installed")
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
user := auth.User{ID: "mail_user_fixture", Username: "mail.reader", Email: "reader@example.test", DisplayName: "Reader", Status: "active", CreatedAt: clock.now(), UpdatedAt: clock.now()}
hash, err := accountMailHash()
if err != nil {
t.Fatal(err)
}
if err = store.CreateUser(t.Context(), user, hash); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-mail-session")), CreatedAt: clock.now(), ExpiresAt: clock.now().Add(24 * time.Hour), LastSeenAt: clock.now()}
if err = store.CreateSession(t.Context(), session); err != nil {
t.Fatal(err)
}
repo, queue, err := store.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(clock))
if err != nil {
t.Fatal(err)
}
return mailFixture{store, repo, queue, service, clock, user, session, path}
}
type collectMail struct{ messages []mail.Message }
func (collector *collectMail) Send(_ context.Context, message mail.Message) error {
collector.messages = append(collector.messages, message)
return nil
}
func (fixture mailFixture) drain(t *testing.T) []mail.Message {
t.Helper()
collector := &collectMail{}
for range 10 {
record, work, err := fixture.queue.ProcessOne(t.Context(), collector)
if err != nil {
t.Fatal(err)
}
if !work {
return collector.messages
}
if record.State != "accepted" {
t.Fatalf("outbox state=%s stage=%s", record.State, record.FailureStage)
}
}
t.Fatal("unbounded fixture queue")
return nil
}
func mailToken(t *testing.T, message mail.Message) string {
t.Helper()
for _, part := range strings.Fields(message.Text) {
parsed, err := url.Parse(part)
if err == nil && parsed.Scheme == "https" && parsed.Query().Get("token") != "" {
if parsed.Host != "accounts.example.test" {
t.Fatal("untrusted action origin")
}
return parsed.Query().Get("token")
}
}
t.Fatal("no action token in fixture mail")
return ""
}
func (fixture mailFixture) verify(t *testing.T) {
t.Helper()
if err := fixture.service.RequestVerification(t.Context(), fixture.session.Digest); err != nil {
t.Fatal(err)
}
messages := fixture.drain(t)
if len(messages) != 1 {
t.Fatalf("verification messages=%d", len(messages))
}
if complete, err := fixture.service.Confirm(t.Context(), mailToken(t, messages[0])); err != nil || !complete {
t.Fatalf("verify: %v %v", complete, err)
}
fixture.clock.advance(time.Minute)
}
func TestMailVerificationResetAndPreservedFactors(t *testing.T) {
f := accountMailFixture(t, 0)
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || verified {
t.Fatalf("legacy auto-verified: %v %v", verified, err)
}
for _, email := range []string{f.user.Email, "unknown@example.test", "not an address"} {
if err := f.service.RequestReset(t.Context(), email); err != nil {
t.Fatal(err)
}
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("unverified/unknown address received reset")
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
if len(messages) != 1 || messages[0].To != f.user.Email {
t.Fatal("verification recipient")
}
token := mailToken(t, messages[0])
for range 2 {
if purpose, err := f.service.Inspect(t.Context(), token); err != nil || purpose != authmail.Verify {
t.Fatalf("inspect: %s %v", purpose, err)
}
}
if verified, _ := f.service.Status(t.Context(), f.session.Digest); verified {
t.Fatal("read-only inspection mutated identity")
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("verify token used for reset: %v", err)
}
if completed, err := f.service.Confirm(t.Context(), token); err != nil || !completed {
t.Fatalf("confirm: %v %v", completed, err)
}
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("token replay: %v", err)
}
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || !verified {
t.Fatalf("not verified: %v %v", verified, err)
}
for _, statement := range []string{
`INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at) VALUES(X'010203','mail_user_fixture','Fixture',X'7B7D',1)`,
`INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES('mail_user_fixture',X'1234',1)`,
`INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(time.Minute)
if err := f.service.RequestReset(t.Context(), strings.ToUpper(f.user.Email)); err != nil {
t.Fatal(err)
}
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal("rate limit disclosed account", err)
}
messages = f.drain(t)
if len(messages) != 1 {
t.Fatalf("reset requests=%d", len(messages))
}
token = mailToken(t, messages[0])
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset token confirmed: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, mailPassword); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("password reuse: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal(err)
}
user, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.ID != f.user.ID || user.Email != f.user.Email || !auth.VerifyPassword(hash, "a different safe password") || auth.VerifyPassword(hash, mailPassword) {
t.Fatal("incorrect reset result", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived: %v", err)
}
for table, want := range map[string]int{"gwf_passkey_credentials": 1, "gwf_recovery_codes": 1, "gwf_recovery_grants": 0, "gwf_passkey_enrollment_tokens": 0, "gwf_account_mail_requests": 0} {
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != want {
t.Fatalf("%s count=%d want=%d err=%v", table, count, want, err)
}
}
if err = f.service.ResetPassword(t.Context(), token, "third different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset replay: %v", err)
}
messages = f.drain(t)
if len(messages) != 1 || !strings.Contains(messages[0].Subject, "password was changed") || strings.Contains(messages[0].Text, "token=") {
t.Fatal("password-change notice missing or carries token")
}
}
func TestMailAddressChangeBothMailboxesAndStableOwnership(t *testing.T) {
for _, oldFirst := range []bool{false, true} {
t.Run(map[bool]string{false: "new-first", true: "old-first"}[oldFirst], func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, "wrong password", "new@example.test"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("unauthenticated change: %v", err)
}
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "NEW@example.test"); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE preserved_purchase(id TEXT PRIMARY KEY,user_id TEXT,old_email TEXT)`,
`INSERT INTO preserved_purchase VALUES('order_fixture','mail_user_fixture','reader@example.test')`,
`INSERT INTO gwf_organizations(id,slug,name,personal,created_at,updated_at) VALUES('family_fixture','family-fixture','Family',0,1,1)`,
`INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES('family_fixture','mail_user_fixture','active',1)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0101','old_invitation','family_fixture','reader@example.test','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0202','new_invitation','family_fixture','new@example.test','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
messages := f.drain(t)
if len(messages) != 2 {
t.Fatalf("change messages=%d", len(messages))
}
var oldToken, newToken string
for _, message := range messages {
if message.To == f.user.Email {
oldToken = mailToken(t, message)
} else if message.To == "new@example.test" {
newToken = mailToken(t, message)
} else {
t.Fatal("wrong mailbox")
}
}
first, second := newToken, oldToken
if oldFirst {
first, second = oldToken, newToken
}
if done, err := f.service.Confirm(t.Context(), first); err != nil || done {
t.Fatalf("first confirmation: %v %v", done, err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("address changed with one mailbox")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("premature change notice")
}
if _, err = f.service.Confirm(t.Context(), first); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("first-leg replay: %v", err)
}
if done, err := f.service.Confirm(t.Context(), second); err != nil || !done {
t.Fatalf("second confirmation: %v %v", done, err)
}
user, _, err = f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != "new@example.test" || user.ID != f.user.ID {
t.Fatal("canonical identity changed incorrectly")
}
if _, _, err = f.store.CredentialByIdentifier(t.Context(), f.user.Email); !errors.Is(err, auth.ErrUserNotFound) {
t.Fatalf("old login address survived: %v", err)
}
if subject, err := f.repo.ResetSubject(t.Context(), "new@example.test", f.clock.now()); err != nil || !subject.Verified {
t.Fatal("new mailbox not verified", err)
}
var value string
if err = f.store.db.QueryRow(`SELECT user_id||':'||old_email FROM preserved_purchase`).Scan(&value); err != nil || value != f.user.ID+":"+f.user.Email {
t.Fatal("historical purchase changed")
}
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != 1 {
t.Fatal("membership changed")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='old_invitation' AND revoked_at IS NOT NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("old invitation retained")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='new_invitation' AND revoked_at IS NULL AND used_at IS NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("new invitation granted or rewritten")
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatal("change did not sign out sessions")
}
if got := f.drain(t); len(got) != 2 {
t.Fatalf("change notices=%d", len(got))
}
})
}
}
func TestMailTokenInvalidation(t *testing.T) {
for _, test := range []struct {
name, statement string
advance time.Duration
}{
{"expiry", "", authmail.Lifetime},
{"password-changed", `UPDATE gwf_password_credentials SET password_hash='new-hash'`, 0},
{"email-changed", `UPDATE gwf_users SET email='elsewhere@example.test',email_normalized='elsewhere@example.test'`, 0},
{"profile-revision", `UPDATE gwf_users SET profile_revision=profile_revision+1`, 0},
{"suspended", `UPDATE gwf_users SET status='suspended'`, 0},
{"pending", `UPDATE gwf_users SET registration_pending=1`, 0},
{"session-revoked", `DELETE FROM gwf_auth_sessions`, 0},
{"forced-password-change", `UPDATE gwf_users SET password_change_required=1`, 0},
} {
t.Run(test.name, func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if test.statement != "" {
if _, err := f.store.db.Exec(test.statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(test.advance)
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("invalidated token accepted: %v", err)
}
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_verified_emails`).Scan(&count); err != nil || count != 0 {
t.Fatal("invalidated verification wrote state")
}
})
}
}
func TestMailAuditAndOutboxRollback(t *testing.T) {
f := accountMailFixture(t, 1)
if _, err := f.store.db.Exec(`CREATE TRIGGER reject_mail_audit BEFORE INSERT ON gwf_audit_events WHEN NEW.action LIKE 'auth.mail.%' BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err == nil {
t.Fatal("audit failure ignored")
}
for _, table := range []string{"gwf_account_mail_requests", "gwf_account_mail_limits", "gwf_mail_outbox"} {
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s not rolled back", table)
}
}
if _, err := f.store.db.Exec(`DROP TRIGGER reject_mail_audit`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err := f.queue.Enqueue(t.Context(), mail.Message{ID: "mail_capacity_fixture", To: "other@example.test", Subject: "Fixture", Text: "Unrelated message", CreatedAt: f.clock.now()}, f.clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, mailsqlite.ErrFull) {
t.Fatalf("outbox failure: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || !auth.VerifyPassword(hash, mailPassword) {
t.Fatal("password changed without committed notification")
}
if _, err = f.service.Inspect(t.Context(), token); err != nil {
t.Fatal("token lost on rollback", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); err != nil {
t.Fatal("session lost on rollback", err)
}
f.drain(t)
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("retry after queue recovery", err)
}
}
func TestMailConcurrentConsumeAndRateLimit(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
var group sync.WaitGroup
var accepted, rejected atomic.Int32
for range 2 {
group.Go(func() {
err := f.service.ResetPassword(context.Background(), token, "a different safe password")
if err == nil {
accepted.Add(1)
} else if errors.Is(err, authmail.ErrUnavailable) {
rejected.Add(1)
} else {
t.Errorf("concurrent consume: %v", err)
}
})
}
group.Wait()
if accepted.Load() != 1 || rejected.Load() != 1 {
t.Fatalf("accepted=%d rejected=%d", accepted.Load(), rejected.Load())
}
g := accountMailFixture(t, 0)
var firstToken string
for request := range 5 {
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal(err)
}
messages := g.drain(t)
if request == 0 {
firstToken = mailToken(t, messages[0])
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("burst limit: %v", err)
}
g.clock.advance(time.Minute)
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("hour limit: %v", err)
}
if _, err := g.service.Inspect(t.Context(), firstToken); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatal("replaced link survived")
}
g.clock.advance(time.Hour)
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal("rate window did not recover", err)
}
}
func TestMailRestartAndAddressCollision(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "new@example.test"); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
first, second := mailToken(t, messages[0]), mailToken(t, messages[1])
if _, err := f.service.Confirm(t.Context(), first); err != nil {
t.Fatal(err)
}
other := f.user
other.ID = "another_mail_user"
other.Username = "another.reader"
other.Email = "new@example.test"
hash, _ := accountMailHash()
if err := f.store.CreateUser(t.Context(), other, hash); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
repo, _, err := reopened.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), Now: f.clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(f.clock))
if err != nil {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), second); !errors.Is(err, authmail.ErrAddressUnavailable) {
t.Fatalf("collision not rechecked: %v", err)
}
if _, err = service.Inspect(t.Context(), second); err != nil {
t.Fatal("collision consumed token", err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("collision overwrote canonical email")
}
var version int
if version, err = f.store.CurrentSchema(t.Context()); err != nil || version != 11 {
t.Fatalf("base schema changed: %d %v", version, err)
}
}
func TestMailCommitUsesFreshTimeAndCredentials(t *testing.T) {
for _, change := range []string{"expiry", "credential"} {
t.Run(change, func(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
config := mailConfig(f.clock)
config.Compose = func(intent authmail.MailIntent) (authmail.MessageContent, error) {
if intent.Kind == "password-changed" {
if change == "expiry" {
f.clock.advance(authmail.Lifetime)
} else {
if _, err := f.store.db.Exec(`UPDATE gwf_password_credentials SET password_hash='concurrent-credential'`); err != nil {
t.Fatal(err)
}
}
}
return authmail.DefaultComposer(intent)
}
service, err := authmail.New(f.repo, config)
if err != nil {
t.Fatal(err)
}
if err = service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("stale completion: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || auth.VerifyPassword(hash, "a different safe password") {
t.Fatal("stale completion changed password")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("uncommitted reset sent notice")
}
})
}
}
func TestMailMigrationRollbackAndMixedCaseLegacyAddress(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "rollback.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err = store.db.Exec(`CREATE TABLE gwf_verified_emails(conflicting_schema TEXT)`); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err == nil {
t.Fatal("schema conflict ignored")
}
var count int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gwf_mail_outbox','gwf_account_mail_migrations')`).Scan(&count); err != nil || count != 0 {
t.Fatal("partial mail migration remained")
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal("base schema damaged", err)
}
f := accountMailFixture(t, 0)
if _, err = f.store.db.Exec(`UPDATE gwf_users SET email='READER@Example.Test'`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err = f.service.RequestReset(t.Context(), "Reader@Example.Test"); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("legacy address reset", err)
}
if notices := f.drain(t); len(notices) != 1 || notices[0].To != "reader@example.test" {
t.Fatal("canonical notice recipient")
}
}
+986
View File
@@ -0,0 +1,986 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"slices"
"strconv"
"time"
"gamertan.com/web/organizations"
)
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership, audit organizations.AuditEvent) error {
if !validOrganization(organization) || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() || !validOrganizationAudit(audit, organization.ID) {
return errors.New("authsqlite: invalid organization")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var personalOwner any
if organization.Personal {
personalOwner = owner.UserID
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, 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(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team, audit organizations.AuditEvent) error {
if !validTeam(team) || !validOrganizationAudit(audit, team.OrganizationID) {
return errors.New("authsqlite: invalid team")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at,status,revision,updated_at) SELECT ?,?,?,?,?,?,?,? FROM gwf_organizations WHERE id=? AND status='active'`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix(), team.Status, team.Revision, team.UpdatedAt.Unix(), team.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrOrganizationNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership, audit organizations.AuditEvent) error {
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
return errors.New("authsqlite: invalid team membership")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at)
SELECT t.id,?,? FROM gwf_teams t
JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active'
JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active'
WHERE t.id=? AND t.status='active' AND t.organization_id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID, audit.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error {
if !opaqueID(project.ID) || !opaqueID(project.OrganizationID) || !slugValue(project.Slug) || !text(project.Name, 128, false) || project.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid project")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_projects(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, project.ID, project.OrganizationID, project.Slug, project.Name, project.CreatedAt.Unix())
return err
}
func (store *Store) CreateEnvironment(ctx context.Context, environment organizations.Environment) error {
if !opaqueID(environment.ID) || !opaqueID(environment.OrganizationID) || !opaqueID(environment.ProjectID) || !slugValue(environment.Slug) || !text(environment.Name, 128, false) || environment.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid environment")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_environments(id,organization_id,project_id,slug,name,created_at)
SELECT ?,?,?,?,?,? FROM gwf_projects WHERE id=? AND organization_id=?`, environment.ID, environment.OrganizationID, environment.ProjectID, environment.Slug, environment.Name, environment.CreatedAt.Unix(), environment.ProjectID, environment.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: project is outside organization")
}
return nil
}
func (store *Store) CreateApplicationService(ctx context.Context, application organizations.ApplicationService) error {
if !opaqueID(application.ID) || !opaqueID(application.OrganizationID) || !opaqueID(application.ProjectID) || !opaqueID(application.EnvironmentID) || !slugValue(application.Slug) || !text(application.Name, 128, false) || application.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid application service")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_application_services(id,organization_id,project_id,environment_id,slug,name,created_at)
SELECT ?,?,?,?,?,?,? FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, application.ID, application.OrganizationID, application.ProjectID, application.EnvironmentID, application.Slug, application.Name, application.CreatedAt.Unix(), application.EnvironmentID, application.ProjectID, application.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: environment is outside project")
}
return nil
}
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
return errors.New("authsqlite: invalid invitation")
}
roles, err := invitation.RoleNames()
if err != nil {
return err
}
if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID {
return errors.New("authsqlite: invalid invitation authority")
}
if ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
rolesJSON, err := json.Marshal(invitation.DirectRoles)
if err != nil {
return err
}
teamIDs, err := json.Marshal(invitation.TeamIDs)
if err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err
}
for _, role := range roles {
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil {
return err
}
if count != 1 {
return errors.New("authsqlite: invitation role has not been seeded")
}
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json,direct_roles_json,required_owner_role)
SELECT ?,?,?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) {
if zeroDigest(digest) || now.IsZero() {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
var invitation organizations.Invitation
var created, expires int64
var teamIDs, rolesJSON []byte
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
if err != nil {
return organizations.Invitation{}, err
}
invitation.Digest = digest
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
invitation.CreatedAt = time.Unix(created, 0).UTC()
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
return invitation, nil
}
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error {
return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit)
}
func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Serialize acceptance before reading token state, including competing users.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
return err
}
var invitationID, organizationID, directRole, invitedBy, requiredOwnerRole string
var teamIDsJSON, rolesJSON []byte
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id,i.direct_roles_json,i.required_owner_role FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized AND u.status='active' AND u.registration_pending=0 JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>? AND NOT EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=i.organization_id AND m.user_id=u.id)`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy, &rolesJSON, &requiredOwnerRole)
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
if err != nil {
return err
}
invitation := organizations.Invitation{DirectRole: directRole, RequiredOwnerRole: requiredOwnerRole}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if audit.OrganizationID != organizationID || audit.ResourceType != "invitation" || audit.ResourceID != invitationID || audit.Action != "invitation.accept" {
return organizations.ErrInvitationNotFound
}
// Stored authority survives which application service receives the link.
// The caller's owner role also protects pre-schema-10 single-role invitations.
if requiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
requiredOwnerRole = ownerRole
}
if err = lockActiveMembershipActor(ctx, tx, organizationID, invitedBy); err != nil {
return err
}
if requiredOwnerRole != "" {
isOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, invitedBy, requiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !isOwner {
return organizations.ErrOwnerAuthority
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?)`, organizationID, userID, acceptedAt.Unix()); err != nil {
return err
}
var teamIDs []string
if json.Unmarshal(teamIDsJSON, &teamIDs) != nil || !validInvitationTeamIDs(teamIDs) {
return organizations.ErrInvitationNotFound
}
if err = validateInvitationTeams(ctx, tx, organizationID, teamIDs); err != nil {
return organizations.ErrInvitationNotFound
}
for _, teamID := range teamIDs {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) VALUES(?,?,?) ON CONFLICT(team_id,user_id) DO NOTHING`, teamID, userID, acceptedAt.Unix()); err != nil {
return err
}
}
for i, role := range roles {
bindingID := "invite-" + invitationID
if len(invitation.DirectRoles) > 0 {
bindingID += "-" + strconv.Itoa(i)
}
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=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:])
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]organizations.Membership, error) {
if !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid user")
}
rows, err := store.db.QueryContext(ctx, `SELECT organization_id,status,joined_at FROM gwf_organization_memberships WHERE user_id=? ORDER BY organization_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []organizations.Membership
for rows.Next() {
var membership organizations.Membership
var joined int64
if err = rows.Scan(&membership.OrganizationID, &membership.Status, &joined); err != nil {
return nil, err
}
membership.UserID = userID
membership.JoinedAt = time.Unix(joined, 0).UTC()
result = append(result, membership)
}
return result, rows.Err()
}
func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization member query")
}
rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at
FROM gwf_organization_memberships m
JOIN gwf_organizations o ON o.id=m.organization_id
WHERE m.organization_id=?
ORDER BY m.joined_at,m.user_id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]organizations.Membership, 0)
for rows.Next() {
var membership organizations.Membership
var joined int64
if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil {
return nil, err
}
membership.OrganizationID = organizationID
membership.JoinedAt = time.Unix(joined, 0).UTC()
result = append(result, membership)
}
return result, rows.Err()
}
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid team query")
}
rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.status,t.revision,t.created_at,t.updated_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id JOIN gwf_organizations o ON o.id=t.organization_id WHERE t.organization_id=? AND tm.user_id=? AND t.status='active' AND o.status='active' ORDER BY t.slug`, organizationID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []organizations.Team
for rows.Next() {
var team organizations.Team
var created, updated int64
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &team.Status, &team.Revision, &created, &updated); err != nil {
return nil, err
}
team.OrganizationID = organizationID
team.CreatedAt = time.Unix(created, 0).UTC()
team.UpdatedAt = time.Unix(updated, 0).UTC()
result = append(result, team)
}
return result, rows.Err()
}
func (store *Store) OrganizationByID(ctx context.Context, organizationID string) (organizations.Organization, error) {
if !opaqueID(organizationID) {
return organizations.Organization{}, organizations.ErrOrganizationNotFound
}
var value organizations.Organization
var personal int
var created, updated int64
err := store.db.QueryRowContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at FROM gwf_organizations WHERE id=?`, organizationID).Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &personal, &value.Revision, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Organization{}, organizations.ErrOrganizationNotFound
}
if err != nil {
return organizations.Organization{}, err
}
value.Personal = personal == 1
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return value, nil
}
func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error {
return store.updateOrganization(ctx, value, expectedRevision, "", audit)
}
func (store *Store) UpdateOwnedOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
if !safeName(ownerRole) || value.Personal || value.Status != "active" || audit.Action != "organization.update" || audit.ResourceType != "organization" || audit.ResourceID != value.ID {
return errors.New("authsqlite: invalid owner-managed organization update")
}
return store.updateOrganization(ctx, value, expectedRevision, ownerRole, audit)
}
func (store *Store) updateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error {
if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
return errors.New("authsqlite: invalid organization update")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if ownerRole != "" {
if err = lockOrganizationOwner(ctx, tx, value.ID, audit.ActorUserID, ownerRole); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) TeamByID(ctx context.Context, organizationID, teamID string) (organizations.Team, error) {
if !opaqueID(teamID) || organizationID != "" && !opaqueID(organizationID) {
return organizations.Team{}, organizations.ErrTeamNotFound
}
query := `SELECT id,organization_id,slug,name,status,revision,created_at,updated_at FROM gwf_teams WHERE id=?`
args := []any{teamID}
if organizationID != "" {
query += ` AND organization_id=?`
args = append(args, organizationID)
}
var value organizations.Team
var created, updated int64
err := store.db.QueryRowContext(ctx, query, args...).Scan(&value.ID, &value.OrganizationID, &value.Slug, &value.Name, &value.Status, &value.Revision, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Team{}, organizations.ErrTeamNotFound
}
if err != nil {
return organizations.Team{}, err
}
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return value, nil
}
func (store *Store) UpdateTeam(ctx context.Context, value organizations.Team, expectedRevision int64, audit organizations.AuditEvent) error {
if !validTeam(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.OrganizationID) {
return errors.New("authsqlite: invalid team update")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_teams SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND organization_id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, value.OrganizationID, expectedRevision)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) RemoveTeamMember(ctx context.Context, teamID, userID string, audit organizations.AuditEvent) error {
if !opaqueID(teamID) || !opaqueID(userID) || !validOrganizationAudit(audit, audit.OrganizationID) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE team_id=? AND user_id=? AND EXISTS (SELECT 1 FROM gwf_teams WHERE id=? AND organization_id=?)`, teamID, userID, teamID, audit.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, userID, status, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(userID) || (status != "active" && status != "suspended") || status != "active" && !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if status != "active" {
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=?`, status, organizationID, userID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
if status != "active" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil {
return err
}
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
return store.changeMembershipStatus(ctx, input, ownerRole, audit, false)
}
func (store *Store) ChangeOwnedMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error {
return store.changeMembershipStatus(ctx, input, ownerRole, audit, true)
}
func (store *Store) changeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error {
if !validMembershipStatusChange(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if requireOwner {
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
} else {
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
}
if err != nil {
return err
}
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
if err != nil {
return err
}
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if input.Status == "suspended" {
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=? AND status=?`, input.Status, input.OrganizationID, input.UserID, input.ExpectedStatus)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if input.Status == "suspended" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
return err
}
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(userID) || !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err
}
if err = revokePendingMembershipInvitations(ctx, tx, organizationID, userID, audit.CreatedAt); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), organizationID, userID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, false)
}
func (store *Store) RemoveOwnedMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error {
return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, true)
}
func (store *Store) removeMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error {
if !validMembershipRemoval(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if requireOwner {
err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole)
} else {
err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID)
}
if err != nil {
return err
}
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
if err != nil {
return err
}
if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict
}
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err
}
if err = revokePendingMembershipInvitations(ctx, tx, input.OrganizationID, input.UserID, audit.CreatedAt); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, input.UserID, input.OrganizationID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), input.OrganizationID, input.UserID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status=?`, input.OrganizationID, input.UserID, input.ExpectedStatus)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrRevisionConflict
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
// Removing a member invalidates older enrollment offers too. A deliberate new
// invitation may be issued later; an old link cannot undo this transaction.
func revokePendingMembershipInvitations(ctx context.Context, tx *sql.Tx, organizationID, userID string, at time.Time) error {
_, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=?
WHERE organization_id=? AND email_normalized=(SELECT email_normalized FROM gwf_users WHERE id=?)
AND used_at IS NULL AND revoked_at IS NULL`, at.Unix(), organizationID, userID)
return err
}
func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, actorUserID string) error {
// Acquire the SQLite write lock before reading the optimistic state. This
// makes a competing lifecycle transaction observe the committed winner.
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
return nil
}
func lockOrganizationOwner(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, ownerRole string) error {
if err := lockActiveMembershipActor(ctx, tx, organizationID, actorUserID); err != nil {
return err
}
var personal bool
if err := tx.QueryRowContext(ctx, `SELECT personal FROM gwf_organizations WHERE id=?`, organizationID).Scan(&personal); err != nil {
return err
}
if personal {
return organizations.ErrPersonalOrganization
}
owner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
if err != nil {
return err
}
if !owner {
return organizations.ErrOwnerAuthority
}
return nil
}
func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
var status string
if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", organizations.ErrMembershipNotFound
}
return "", err
}
if status != "active" && status != "suspended" {
return "", errors.New("authsqlite: stored membership status is invalid")
}
return status, nil
}
func requireOwnerAuthorityForOwnerTarget(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, targetUserID, ownerRole string) error {
targetIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, targetUserID, ownerRole)
if err != nil || !targetIsOwner {
return err
}
actorIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole)
if err != nil {
return err
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
return nil
}
func hasDirectOwnerRole(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) (bool, error) {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
var targetIsOwner int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil {
return err
}
if targetIsOwner == 0 {
return nil
}
var otherActiveOwners int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
return err
}
if otherActiveOwners == 0 {
return organizations.ErrLastOwner
}
return nil
}
func validMembershipStatusChange(input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) bool {
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
(input.Status == "active" || input.Status == "suspended") && input.ExpectedStatus != input.Status &&
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
audit.Action == "membership."+input.Status && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
}
func validMembershipRemoval(input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) bool {
return opaqueID(input.OrganizationID) && opaqueID(input.UserID) && opaqueID(input.ActorUserID) && safeName(ownerRole) &&
(input.ExpectedStatus == "active" || input.ExpectedStatus == "suspended") &&
validOrganizationAudit(audit, input.OrganizationID) && audit.ActorUserID == input.ActorUserID &&
audit.Action == "membership.remove" && audit.ResourceType == "membership" && audit.ResourceID == input.UserID && audit.RequestID == input.RequestID
}
func (store *Store) Invitations(ctx context.Context, organizationID string, limit int) ([]organizations.Invitation, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("authsqlite: invalid invitation query")
}
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]organizations.Invitation, 0)
for rows.Next() {
var value organizations.Invitation
var created, expires, used, revoked int64
var teamIDs, rolesJSON []byte
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &rolesJSON, &value.RequiredOwnerRole, &created, &expires, &used, &revoked); err != nil {
return nil, err
}
if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) {
return nil, errors.New("authsqlite: stored invitation is invalid")
}
if !decodeInvitationRoles(&value, rolesJSON) {
return nil, errors.New("authsqlite: stored invitation roles are invalid")
}
value.OrganizationID = organizationID
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if used != 0 {
value.UsedAt = time.Unix(used, 0).UTC()
}
if revoked != 0 {
value.RevokedAt = time.Unix(revoked, 0).UTC()
}
result = append(result, value)
}
return result, rows.Err()
}
func validInvitationTeamIDs(teamIDs []string) bool {
if len(teamIDs) > 16 {
return false
}
seen := make(map[string]struct{}, len(teamIDs))
for _, teamID := range teamIDs {
if !opaqueID(teamID) {
return false
}
if _, exists := seen[teamID]; exists {
return false
}
seen[teamID] = struct{}{}
}
return true
}
func decodeInvitationRoles(invitation *organizations.Invitation, raw []byte) bool {
if len(raw) > 4096 || json.Unmarshal(raw, &invitation.DirectRoles) != nil || invitation.RequiredOwnerRole != "" && !safeName(invitation.RequiredOwnerRole) {
return false
}
_, err := invitation.RoleNames()
return err == nil
}
func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
for _, teamID := range teamIDs {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_teams WHERE id=? AND organization_id=? AND status='active'`, teamID, organizationID).Scan(&count); err != nil {
return err
}
if count != 1 {
return organizations.ErrTeamNotFound
}
}
return nil
}
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var invitation organizations.Invitation
var rolesJSON []byte
if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func validOrganization(value organizations.Organization) bool {
return opaqueID(value.ID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero()
}
func validTeam(value organizations.Team) bool {
return opaqueID(value.ID) && opaqueID(value.OrganizationID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero()
}
func validOrganizationAudit(value organizations.AuditEvent, organizationID string) bool {
return opaqueID(value.ID) && opaqueID(organizationID) && value.OrganizationID == organizationID && opaqueID(value.ActorUserID) && text(value.Action, 128, false) && text(value.ResourceType, 128, false) && text(value.ResourceID, 128, false) && text(value.RequestID, 128, true) && text(value.Summary, 512, false) && !value.CreatedAt.IsZero()
}
func appendOrganizationAudit(ctx context.Context, tx *sql.Tx, value organizations.AuditEvent) error {
_, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, value.ID, value.OrganizationID, value.ActorUserID, value.Action, value.ResourceType, value.ResourceID, value.RequestID, value.Summary, value.CreatedAt.Unix())
return err
}
func slugValue(value string) bool {
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
return false
}
for _, character := range value {
if character != '-' && (character < 'a' || character > 'z') && (character < '0' || character > '9') {
return false
}
}
return true
}
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"testing"
"gamertan.com/web/organizations"
)
type ownedOperation struct {
name, action string
apply func(context.Context, roleSetFixture, string, string) error
}
func ownedOperations() []ownedOperation {
return []ownedOperation{
{"profile", "organization.update", func(ctx context.Context, f roleSetFixture, actor, _ string) error {
_, err := f.organizations.UpdateOwnedOrganization(ctx, organizations.UpdateOrganization{ID: f.org.ID, Slug: "updated-business", Name: "Updated business", ActorUserID: actor, ExpectedRevision: 1, RequestID: "request-profile"})
return err
}},
{"suspend", "membership.suspended", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.ChangeOwnedMembershipStatus(ctx, organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", Status: "suspended", RequestID: "request-status"})
}},
{"remove", "membership.remove", func(ctx context.Context, f roleSetFixture, actor, target string) error {
return f.organizations.RemoveOwnedMembershipIfCurrent(ctx, organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", RequestID: "request-remove"})
}},
}
}
func TestOwnedManagementRechecksActorInWriteTransaction(t *testing.T) {
for _, operation := range ownedOperations() {
for _, change := range []struct{ name, sql string }{
{"role revoked", `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`},
{"role narrowed", `UPDATE gwf_access_bindings SET project_id=(SELECT id FROM gwf_projects LIMIT 1) WHERE subject_id='customer-12345'`},
{"actor suspended", `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`},
{"actor removed", `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`},
{"account disabled", `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`},
{"registration incomplete", `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`},
{"organization archived", `UPDATE gwf_organizations SET status='archived'`},
{"personal organization", `UPDATE gwf_organizations SET personal=1,personal_owner_user_id='customer-12345'`},
} {
t.Run(operation.name+"/"+change.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if change.name == "role narrowed" {
if _, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}); err != nil {
t.Fatal(err)
}
}
// Model a change committed after the caller displayed/authorized the
// operation. The repository must not rely on that earlier decision.
if _, err := f.store.db.Exec(change.sql); err != nil {
t.Fatal(err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("stale owner authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
})
}
}
}
func TestOwnedManagementDoesNotInheritDelegatedAdministratorSemantics(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if err := operation.apply(t.Context(), f, roleMember, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner management: %v", err)
}
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("replayed mutation accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
// Legacy callers still authorize non-owner administrative operations in
// their application policy; the new explicit methods do not alter that API.
f := newRoleSetFixture(t)
f.addMember(t)
err := f.organizations.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleMember, ExpectedStatus: "active", Status: "suspended"})
if err != nil {
t.Fatalf("delegated legacy operation changed: %v", err)
}
}
func TestOwnedMembershipPreservesLastOwnerAndRestoresSuspendedMember(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
for _, operation := range ownedOperations()[1:] {
if err := operation.apply(t.Context(), f, roleOwner, roleOwner); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("%s last owner: %v", operation.name, err)
}
}
if err := ownedOperations()[1].apply(t.Context(), f, roleOwner, roleMember); err != nil {
t.Fatal(err)
}
input := organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "suspended", Status: "active"}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); err != nil {
t.Fatal(err)
}
if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale reactivation: %v", err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
}
func TestOwnedManagementRollsBackWithAuditFailure(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
_, pending := f.invite(t, "buyer")
team, err := f.organizations.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: f.org.ID, Slug: "team", Name: "Team", ActorUserID: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.organizations.AddTeamMember(t.Context(), team.ID, roleMember, roleOwner); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`CREATE TRIGGER reject_owned_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='` + operation.action + `' BEGIN SELECT RAISE(ABORT,'injected audit failure'); END`); err != nil {
t.Fatal(err)
}
if err = operation.apply(t.Context(), f, roleOwner, roleMember); err == nil {
t.Fatal("audit failure accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NULL`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL`, pending.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0)
})
}
}
func TestConcurrentOwnedManagementHasOneWinner(t *testing.T) {
for _, operation := range ownedOperations() {
t.Run(operation.name, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
start, results := make(chan struct{}), make(chan error, 2)
for range 2 {
go func() { <-start; results <- operation.apply(t.Context(), f, roleOwner, roleMember) }()
}
close(start)
success, stale := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, organizations.ErrRevisionConflict), errors.Is(err, organizations.ErrMembershipNotFound):
stale++
default:
t.Fatalf("concurrent mutation: %v", err)
}
}
if success != 1 || stale != 1 {
t.Fatalf("success=%d stale=%d", success, stale)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1)
})
}
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/organizations"
)
// CreateOwnedOrganization atomically creates a new organization and its first
// direct owner. It never grants authority in an existing organization.
func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error {
organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding
audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit
if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) ||
!validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) ||
!validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID ||
!validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID {
return errors.New("authsqlite: invalid owned organization")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var personalOwner any
if organization.Personal {
personalOwner = membership.UserID
}
// The first statement acquires the writer lock and validates active, completed
// identity inside the transaction; account suspension cannot race the grant.
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at)
SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`,
organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner,
organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return organizations.ErrOwnerAuthority
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, 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, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, err := result.RowsAffected(); err != nil || changed != 1 {
if err != nil {
return err
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendOrganizationAudit(ctx, tx, audit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ organizations.OwnedOrganizationRepository = (*Store)(nil)
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "owned.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Unix(2000, 0).UTC()
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"},
Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"},
Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.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)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"}
}
func countOwnedRows(t *testing.T, store *Store, want int) {
t.Helper()
for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
t.Fatal(err)
}
expected := want
if table == "gwf_access_audit_events" {
expected *= 2
}
if count != expected {
t.Errorf("%s count=%d want=%d", table, count, expected)
}
}
}
func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) {
store, service, policy, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
countOwnedRows(t, store, 1)
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
scope string
permission string
want bool
}{
{organization.ID, "customer.purchase", true},
{organization.ID, "merchant.manage", false},
{"other-org-12345", "customer.purchase", false},
} {
decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission)
if err != nil || decision.Allowed != test.want {
t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err)
}
}
for _, action := range []string{"organization.create", "access.binding.grant"} {
var actor, request string
if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil {
t.Fatal(err)
}
if actor != input.OwnerUserID || request != input.RequestID {
t.Fatalf("audit actor=%q request=%q", actor, request)
}
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil {
t.Fatal("duplicate slug accepted")
}
countOwnedRows(t, store, 1)
}
func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) {
for _, stage := range []struct{ table, when string }{
{"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""},
{"gwf_access_audit_events", " WHEN NEW.action='organization.create'"},
{"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"},
} {
t.Run(stage.table+stage.when, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) {
for _, change := range []string{
`DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_users WHERE id='customer-12345'`,
} {
t.Run(change, func(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := store.db.Exec(change); err != nil {
t.Fatal(err)
}
if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
countOwnedRows(t, store, 0)
})
}
}
func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
var workers sync.WaitGroup
results := make(chan error, 8)
for range 8 {
workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err })
}
workers.Wait()
close(results)
winners := 0
for err := range results {
if err == nil {
winners++
}
}
if winners != 1 {
t.Fatalf("successful creations=%d", winners)
}
countOwnedRows(t, store, 1)
}
func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
if _, err := service.CreateOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
var bindings int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil {
t.Fatal(err)
}
if bindings != 0 {
t.Fatal("legacy creation unexpectedly granted authority")
}
}
func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) {
store, service, _, input := ownedOrganizationFixture(t)
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
var sequence int
var name, path string
if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil {
t.Fatal(err)
}
if err = store.Close(); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
countOwnedRows(t, reopened, 1)
service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner removal: %v", err)
}
err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{
OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended",
})
if !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last owner suspension: %v", err)
}
countOwnedRows(t, reopened, 1)
}
type capturedOwnedStore struct {
*Store
setup organizations.OwnedOrganization
}
func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error {
store.setup = setup
return nil
}
func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) {
for _, test := range []struct {
name string
change func(*organizations.OwnedOrganization)
}{
{"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }},
{"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }},
{"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }},
{"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }},
{"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }},
{"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }},
{"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }},
{"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }},
{"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }},
{"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }},
} {
t.Run(test.name, func(t *testing.T) {
store, _, _, input := ownedOrganizationFixture(t)
capture := &capturedOwnedStore{Store: store}
service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil {
t.Fatal(err)
}
test.change(&capture.setup)
if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil {
t.Fatal("invalid creation accepted")
}
countOwnedRows(t, store, 0)
})
}
}
+383
View File
@@ -0,0 +1,383 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const (
maxPasskeysPerUser = 16
maxCredentialBytes = 64 << 10
maxCeremonySessionBytes = 64 << 10
)
func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) error {
if !validPasskeyUser(user) || !validEnrollment(enrollment) || enrollment.UserID != user.ID || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey 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, 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 {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, error) {
if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound
}
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) {
identifier = strings.TrimSpace(identifier)
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,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.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
}
return user, err
}
func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]authwebauthn.Credential, error) {
if !opaqueID(userID) {
return nil, auth.ErrUserNotFound
}
rows, err := store.db.QueryContext(ctx, `SELECT credential_id,label,credential_json,created_at,COALESCE(last_used_at,0) FROM gwf_passkey_credentials WHERE user_id=? ORDER BY created_at,credential_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
credentials := make([]authwebauthn.Credential, 0)
for rows.Next() {
var credential authwebauthn.Credential
var created, used int64
if err = rows.Scan(&credential.ID, &credential.Label, &credential.Data, &created, &used); err != nil {
return nil, err
}
credential.UserID = userID
credential.CreatedAt = time.Unix(created, 0).UTC()
if used != 0 {
credential.LastUsedAt = time.Unix(used, 0).UTC()
}
credentials = append(credentials, credential)
}
return credentials, rows.Err()
}
func (store *Store) PasswordCredentialExists(ctx context.Context, userID string) (bool, error) {
if !opaqueID(userID) {
return false, auth.ErrUserNotFound
}
var count int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, userID).Scan(&count); err != nil {
return false, err
}
return count == 1, nil
}
func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
if !validCredential(credential, true) || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&count); err != nil {
return err
}
if count >= maxPasskeysPerUser {
return errors.New("authsqlite: passkey credential limit reached")
}
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, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) SaveCredentialAndRetirePassword(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
if !validCredential(credential, true) || !validAuditEvent(audit) || audit.ActorUserID != credential.UserID || audit.Action != "auth.passkey.migrate" {
return errors.New("authsqlite: invalid passkey migration")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var credentialCount, passwordCount int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&credentialCount); err != nil {
return err
}
if credentialCount >= maxPasskeysPerUser {
return errors.New("authsqlite: passkey credential limit reached")
}
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, credential.UserID).Scan(&passwordCount); err != nil {
return err
}
if passwordCount != 1 {
return authwebauthn.ErrPasswordNotAvailable
}
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, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_password_credentials WHERE user_id=?`, credential.UserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return authwebauthn.ErrPasswordNotAvailable
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, credential.CreatedAt.Unix(), credential.UserID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, credential.UserID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, credential.UserID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, credential.UserID); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error {
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
return errors.New("authsqlite: invalid passkey credential update")
}
result, err := store.db.ExecContext(ctx, `UPDATE gwf_passkey_credentials SET credential_json=?,last_used_at=? WHERE credential_id=? AND user_id=?`, []byte(credential.Data), credential.LastUsedAt.Unix(), credential.ID, credential.UserID)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return authwebauthn.ErrCredentialNotFound
}
return nil
}
func (store *Store) DeleteCredential(ctx context.Context, userID string, credentialID []byte, minimumRemaining int, audit auth.AuditEvent) error {
if !opaqueID(userID) || !boundedCredentialID(credentialID) || minimumRemaining < 1 || minimumRemaining > maxPasskeysPerUser || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential deletion")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count); err != nil {
return err
}
if count <= minimumRemaining {
if minimumRemaining == 1 {
return authwebauthn.ErrLastCredential
}
return authwebauthn.ErrCredentialFloor
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_passkey_credentials WHERE user_id=? AND credential_id=?`, userID, credentialID)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return authwebauthn.ErrCredentialNotFound
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CredentialCount(ctx context.Context, userID string) (int, error) {
if !opaqueID(userID) {
return 0, auth.ErrUserNotFound
}
var count int
err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count)
return count, err
}
func (store *Store) CreateCeremony(ctx context.Context, ceremony authwebauthn.Ceremony) error {
if !validCeremony(ceremony) {
return errors.New("authsqlite: invalid passkey ceremony")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_passkey_ceremonies(token_hash,kind,user_id,label,session_json,binding_hash,created_at,expires_at) VALUES(?,?,NULLIF(?,''),?,?,?,?,?)`, ceremony.Digest[:], ceremony.Kind, ceremony.UserID, ceremony.Label, []byte(ceremony.SessionData), ceremony.BindingDigest[:], ceremony.CreatedAt.Unix(), ceremony.ExpiresAt.Unix())
return err
}
func (store *Store) TakeCeremony(ctx context.Context, digest [32]byte, now time.Time) (authwebauthn.Ceremony, error) {
if zeroDigest(digest) || now.IsZero() {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
var ceremony authwebauthn.Ceremony
var userID sql.NullString
var binding []byte
var created, expires int64
err := store.db.QueryRowContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE token_hash=? RETURNING kind,user_id,label,session_json,binding_hash,created_at,expires_at`, digest[:]).Scan(&ceremony.Kind, &userID, &ceremony.Label, &ceremony.SessionData, &binding, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
if err != nil {
return authwebauthn.Ceremony{}, err
}
ceremony.Digest = digest
ceremony.UserID = userID.String
copy(ceremony.BindingDigest[:], binding)
ceremony.CreatedAt, ceremony.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if len(binding) != sha256Size || !now.Before(ceremony.ExpiresAt) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
return ceremony, nil
}
func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], now.Unix()).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
if 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
}
func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) (auth.User, error) {
if !text(strings.TrimSpace(identifier), 320, false) || !validEnrollment(enrollment) || !validAuditEvent(audit) {
return auth.User{}, errors.New("authsqlite: invalid passkey recovery")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
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
}
if enrollment.UserID != user.ID || audit.ResourceID != user.ID {
return auth.User{}, errors.New("authsqlite: passkey recovery identity mismatch")
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], user.ID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User
var passwordChangeRequired, registrationPending int
var created, updated int64
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.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
return !zeroDigest(token.Digest) && opaqueID(token.UserID) && !token.CreatedAt.IsZero() && token.ExpiresAt.After(token.CreatedAt)
}
func boundedCredentialID(value []byte) bool { return len(value) >= 16 && len(value) <= 1024 }
func validCredential(credential authwebauthn.Credential, requireLabel bool) bool {
return boundedCredentialID(credential.ID) && opaqueID(credential.UserID) && (!requireLabel || text(credential.Label, 80, false)) && len(credential.Data) > 0 && len(credential.Data) <= maxCredentialBytes && json.Valid(credential.Data) && (!requireLabel || !credential.CreatedAt.IsZero())
}
func validCeremony(ceremony authwebauthn.Ceremony) bool {
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID)
registrationKind := ceremony.Kind == authwebauthn.CeremonyRegistration
validLabel := registrationKind && text(ceremony.Label, 80, false) || !registrationKind && ceremony.Label == ""
zeroBinding := zeroDigest(ceremony.BindingDigest)
validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin && zeroBinding
return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt)
}
const sha256Size = 32
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyCredentialDeletionPreservesConfiguredFloor(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
user := auth.User{ID: "passkey-user-id", Username: "passkey.user", Email: "passkey@example.test", DisplayName: "Passkey User", Status: "active", CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: [32]byte{1}, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err = store.CreatePasskeyUser(t.Context(), user, enrollment, testAudit("bootstrap-audit", "auth.passkey.bootstrap", user.ID, now)); err != nil {
t.Fatal(err)
}
ids := [][]byte{bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)}
for index, id := range ids {
encoded, marshalErr := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Credential", Data: encoded, CreatedAt: now}
if err = store.SaveCredential(t.Context(), credential, testAudit("add-audit-"+string(rune('a'+index)), "auth.passkey.add", user.ID, now)); err != nil {
t.Fatal(err)
}
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[0], 2, testAudit("delete-audit", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 2, testAudit("delete-floor", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential floor err=%v", err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 1, testAudit("delete-second", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[2], 1, testAudit("delete-last", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrLastCredential) {
t.Fatalf("last credential err=%v", err)
}
}
func TestPasskeyCeremonyIsConsumedExactlyOnceConcurrently(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
digest := [32]byte{1, 2, 3}
if err = store.CreateCeremony(t.Context(), authwebauthn.Ceremony{Digest: digest, Kind: authwebauthn.CeremonyLogin, SessionData: []byte(`{"challenge":"example"}`), CreatedAt: now, ExpiresAt: now.Add(time.Minute)}); err != nil {
t.Fatal(err)
}
var successes atomic.Int32
unexpected := make(chan error, 16)
var group sync.WaitGroup
for range 16 {
group.Add(1)
go func() {
defer group.Done()
_, takeErr := store.TakeCeremony(t.Context(), digest, now)
if takeErr == nil {
successes.Add(1)
return
}
if !errors.Is(takeErr, authwebauthn.ErrCeremonyNotFound) {
unexpected <- takeErr
}
}()
}
group.Wait()
close(unexpected)
for value := range unexpected {
t.Fatalf("unexpected concurrent error: %v", value)
}
if successes.Load() != 1 {
t.Fatalf("successful consumes=%d", successes.Load())
}
}
func TestPasskeyMigrationAtomicallyRetiresPasswordAndSessions(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "migrate.me", Email: "migrate@example.test", DisplayName: "Migration Test", Password: "legacy password credential"})
if err != nil {
t.Fatal(err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
id := bytes.Repeat([]byte{7}, 32)
encoded, err := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}})
if err != nil {
t.Fatal(err)
}
credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Primary passkey", Data: encoded, CreatedAt: now}
audit := auth.AuditEvent{ID: "migration-audit", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now}
if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); err != nil {
t.Fatal(err)
}
if exists, existsErr := store.PasswordCredentialExists(t.Context(), user.ID); existsErr != nil || exists {
t.Fatalf("password exists=%v err=%v", exists, existsErr)
}
if _, _, err = authService.Authenticate(t.Context(), user.Username, "legacy password credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("legacy password still authenticates: %v", err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived migration: %v", err)
}
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, id) {
t.Fatalf("credentials=%+v err=%v", credentials, err)
}
if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) {
t.Fatalf("migration replay err=%v", err)
}
}
func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent {
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"math"
"time"
"gamertan.com/web/auth"
"modernc.org/sqlite"
)
var _ auth.OwnProfileRepository = (*Store)(nil)
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
AND u.registration_pending=0 AND u.password_change_required=0`
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
var profile auth.OwnProfile
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
if errors.Is(err, sql.ErrNoRows) {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return profile, err
}
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
if zeroDigest(session) || now.IsZero() {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
}
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
return auth.OwnProfile{}, auth.ErrProfileInput
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.OwnProfile{}, err
}
defer tx.Rollback()
// This first statement takes the writer lock and tests the real current
// session/account/revision together. No read-before-write lock upgrade race.
set := `display_name=?`
args := []any{value}
if change.Field == "username" {
set = `username=?,username_normalized=?`
args = append(args, normalize(value))
}
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
}
return auth.OwnProfile{}, err
}
changed, err := result.RowsAffected()
if err != nil {
return auth.OwnProfile{}, err
}
if changed != 1 {
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if profile.UserID != change.UserID {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return auth.OwnProfile{}, auth.ErrProfileConflict
}
if change.Field == "username" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
return auth.OwnProfile{}, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.OwnProfile{}, err
}
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if err = tx.Commit(); err != nil {
return auth.OwnProfile{}, err
}
return profile, nil
}
+212
View File
@@ -0,0 +1,212 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/web/auth"
)
type profileFixture struct {
store *Store
path string
now time.Time
user auth.User
session, other auth.Session
}
func newProfileFixture(t *testing.T) profileFixture {
t.Helper()
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
now := time.Now().UTC().Truncate(time.Second)
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
other := session
other.Digest = sha256.Sum256([]byte("other-session"))
for _, s := range []auth.Session{session, other} {
if err = store.CreateSession(t.Context(), s); err != nil {
t.Fatal(err)
}
}
return profileFixture{store, path, now, user, session, other}
}
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
}
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
f := newProfileFixture(t)
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || initial.Revision != 1 {
t.Fatalf("initial revision: %d %v", initial.Revision, err)
}
change, audit := f.change("display_name", " Émilie ★ ", 1)
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
t.Fatalf("display update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("display edit revoked session", err)
}
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("stale: %v", err)
}
change, audit = f.change("username", "new.reader", 2)
change.ExpectedPasswordHash = "fixture-hash"
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
t.Fatalf("username update: %+v %v", updated, err)
}
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("other session survived: %v", err)
}
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
t.Fatal("credential identity changed", err)
}
var count int
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
t.Fatalf("audits: %d %v", count, err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
t.Fatalf("restart: %+v %v", recovered, err)
}
}
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
for _, test := range []struct{ name, sql string }{
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
{"suspended", `UPDATE gwf_users SET status='suspended'`},
{"disabled", `UPDATE gwf_users SET status='disabled'`},
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
} {
t.Run(test.name, func(t *testing.T) {
f := newProfileFixture(t)
if _, err := f.store.db.Exec(test.sql); err != nil {
t.Fatal(err)
}
change, audit := f.change("display_name", "not allowed", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("access: %v", err)
}
})
}
f := newProfileFixture(t)
change, audit := f.change("username", "new.reader", 1)
change.UserID = "another-user"
audit.ActorUserID = change.UserID
audit.ResourceID = change.UserID
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
t.Fatalf("foreign user: %v", err)
}
change, audit = f.change("username", "new.reader", 1)
change.ExpectedPasswordHash = "old-verified-hash"
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
t.Fatalf("changed password: %v", err)
}
change.ExpectedPasswordHash = "fixture-hash"
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
t.Fatal("duplicate audit accepted")
}
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
t.Fatalf("rollback: %+v %v", profile, err)
}
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
t.Fatal("audit failure revoked session", err)
}
}
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
f := newProfileFixture(t)
otherUser := f.user
otherUser.ID = "another-user"
otherUser.Username = "another.reader"
otherUser.Email = "another@example.test"
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
t.Fatal(err)
}
change, audit := f.change("username", "ANOTHER.READER", 1)
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
t.Fatalf("unique name: %v", err)
}
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer second.Close()
var wg sync.WaitGroup
results := make(chan error, 2)
for _, store := range []*Store{f.store, second} {
wg.Add(1)
go func(store *Store) {
defer wg.Done()
change, audit := f.change("display_name", "New Name", 1)
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
results <- err
}(store)
}
wg.Wait()
close(results)
success, conflict := 0, 0
for err := range results {
if err == nil {
success++
} else if errors.Is(err, auth.ErrProfileConflict) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
}
// Recreate the actual previous schema without rewriting its identity rows.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
t.Fatal(err)
}
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
t.Fatal(err)
}
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
t.Fatalf("prior schema: %d %v", version, err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("startup accepted old schema")
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
t.Fatalf("migration: %+v %v", profile, err)
}
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal("idempotent migration", err)
}
}
+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
}
+488
View File
@@ -0,0 +1,488 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"slices"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
type roleSetFixture struct {
store *Store
access *access.Service
organizations *organizations.Service
org organizations.Organization
now time.Time
}
const roleOwner = "customer-12345"
const roleMember = "member-12345678"
func newRoleSetFixture(t *testing.T) roleSetFixture {
t.Helper()
store, _, policy, input := ownedOrganizationFixture(t)
policy.Roles["buyer"] = "Buyer"
policy.Roles["billing"] = "Billing manager"
policy.Roles["member"] = "Member"
policy.Permissions["billing.manage"] = "Manage billing"
policy.Grants["buyer"] = []string{"customer.purchase"}
policy.Grants["billing"] = []string{"billing.manage"}
policy.Grants["member"] = nil
now := time.Unix(2100, 0).UTC()
accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
org, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil {
t.Fatal(err)
}
return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now}
}
func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) {
t.Helper()
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"})
if err != nil {
t.Fatal(err)
}
return raw, invitation
}
func (f roleSetFixture) addMember(t *testing.T) {
t.Helper()
raw, _ := f.invite(t, "member")
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
}
func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) {
t.Helper()
bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100)
if err != nil {
t.Fatal(err)
}
var ids, roles []string
for _, binding := range bindings {
if binding.SubjectID == user {
ids = append(ids, binding.ID)
roles = append(roles, binding.Role)
}
}
slices.Sort(ids)
slices.Sort(roles)
return ids, roles
}
func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error {
t.Helper()
ids, _ := f.bindings(t, target)
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids})
return err
}
func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"})
if err != nil {
t.Fatal(err)
}
narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil {
t.Fatal(err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
for _, permission := range []string{"customer.purchase", "billing.manage"} {
decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission)
if err != nil || !decision.Allowed {
t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err)
}
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged=%v", err)
}
if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner bulk change=%v", err)
}
if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last owner=%v", err)
}
if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("new last owner=%v", err)
}
}
func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
ids, _ := f.bindings(t, roleMember)
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids})
results <- err
}()
}
close(start)
success, conflict := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, access.ErrRoleChangeConflict):
conflict++
default:
t.Fatalf("concurrent error=%v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d", success, conflict)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
}
func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) {
for _, stage := range []string{"second binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
before, _ := f.bindings(t, roleMember)
var statement string
switch stage {
case "second binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil {
t.Fatal("write failure accepted")
}
after, roles := f.bindings(t, roleMember)
if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) {
t.Fatalf("after=%v roles=%v", after, roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0)
})
}
}
func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil {
t.Fatal(err)
}
if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole)
}
listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10)
if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) {
t.Fatalf("listed=%v err=%v", listed, err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1)
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil {
t.Fatal("wrong email accepted")
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }()
}
close(start)
success := 0
for range 2 {
if err := <-results; err == nil {
success++
} else if !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("accept error=%v", err)
}
}
if success != 1 {
t.Fatalf("accepted=%d", success)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("replay=%v", err)
}
}
func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) {
for _, mutation := range []string{
`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`,
`UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`,
`UPDATE gwf_organizations SET status='archived'`,
`UPDATE gwf_organization_invitations SET expires_at=2100`,
`UPDATE gwf_organization_invitations SET revoked_at=2100`,
`UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`,
} {
t.Run(mutation, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
if _, err := f.store.db.Exec(mutation); err != nil {
t.Fatal(err)
}
// Stored authority still applies through a differently configured service.
other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }})
if err != nil {
t.Fatal(err)
}
if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("stale or invalid authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
})
}
}
func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member invite=%v", err)
}
raw, invitation := f.invite(t, "buyer", "billing")
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member revoke=%v", err)
}
if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("invitation reactivated suspended member")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1)
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil {
t.Fatal(err)
}
}
func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) {
for _, stage := range []string{"membership", "binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
var statement string
switch stage {
case "membership":
statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END`
case "binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("partial acceptance succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0)
})
}
}
func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
// Reconstruct the previous invitation schema in this disposable database.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
ALTER TABLE gwf_users DROP COLUMN profile_revision;
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
t.Fatal(err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("old schema accepted without migration")
}
for range 2 {
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
}
if err = f.store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" {
t.Fatalf("legacy changed: %+v err=%v", stored, err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy owner authority=%v", err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
ids, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) {
t.Fatalf("legacy IDs=%v roles=%v", ids, roles)
}
}
func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) {
f := newRoleSetFixture(t)
service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()}
for _, field := range []string{"actor", "resource", "action"} {
bad := audit
switch field {
case "actor":
bad.ActorUserID = roleOwner
case "resource":
bad.ResourceID = "invitation-other"
case "action":
bad.Action = "invitation.create"
}
if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil {
t.Fatalf("mismatched audit %s accepted", field)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
}
if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatalf("real clock acceptance=%v", err)
}
}
func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) {
for _, fail := range []string{"unknown role", "audit"} {
t.Run(fail, func(t *testing.T) {
f := newRoleSetFixture(t)
roles := []string{"billing", "buyer"}
if fail == "unknown role" {
roles = append(roles, "unknown")
} else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour})
if err == nil || raw != "" || invitation.ID != "" {
t.Fatal("failed creation returned invitation")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0)
})
}
}
func TestRoleInvitationCannotBypassMembershipChanges(t *testing.T) {
for _, optimistic := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy removal", true: "optimistic removal"}[optimistic], func(t *testing.T) {
f := newRoleSetFixture(t)
oldToken, oldInvitation := f.invite(t, "buyer", "billing")
f.addMember(t)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite elevated existing member=%v", err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"member"}) {
t.Fatalf("roles changed=%v", roles)
}
remove := func() error {
if optimistic {
return f.organizations.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "active", RequestID: "request-remove"})
}
return f.organizations.RemoveMembership(t.Context(), f.org.ID, roleMember, roleOwner, "request-remove")
}
if _, err := f.store.db.Exec(`CREATE TRIGGER fail_removal BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='membership.remove' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := remove(); err == nil {
t.Fatal("unaudited removal succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if _, err := f.store.db.Exec(`DROP TRIGGER fail_removal`); err != nil {
t.Fatal(err)
}
if err := remove(); err != nil {
t.Fatal(err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NOT NULL AND used_at IS NULL`, oldInvitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("old invite restored removed member=%v", err)
}
newToken, _ := f.invite(t, "buyer")
if err := f.organizations.AcceptInvitation(t.Context(), newToken, roleMember); err != nil {
t.Fatalf("intentional fresh invitation=%v", err)
}
_, roles = f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"buyer"}) {
t.Fatalf("fresh roles=%v", roles)
}
})
}
}
+278 -7
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 = 11
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')), 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)`,
@@ -88,18 +127,150 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_credentials (credential_id BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, credential_json BLOB NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_credentials_user ON gwf_passkey_credentials(user_id,created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_enrollment_tokens (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_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_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
`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)`,
`CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, 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, UNIQUE(organization_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`,
`CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, id TEXT NOT NULL UNIQUE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), direct_role TEXT NOT NULL DEFAULT '', team_ids_json BLOB NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_access_role_permissions (role_name TEXT NOT NULL REFERENCES gwf_access_roles(name) ON DELETE CASCADE, permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name) ON DELETE CASCADE, PRIMARY KEY(role_name,permission_name))`,
`CREATE TABLE IF NOT EXISTS gwf_access_bindings (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, subject_kind TEXT NOT NULL CHECK(subject_kind IN ('user','team')), subject_id TEXT NOT NULL, role_name TEXT NOT NULL REFERENCES gwf_access_roles(name), project_id TEXT, environment_id TEXT, service_id TEXT, granted_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), granted_at INTEGER NOT NULL, revoked_by_user_id TEXT REFERENCES gwf_users(id), revoked_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_access_bindings_scope ON gwf_access_bindings(organization_id,subject_kind,subject_id,revoked_at)`,
`CREATE TABLE IF NOT EXISTS gwf_break_glass (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id), permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name), reason TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_break_glass_active ON gwf_break_glass(organization_id,user_id,expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_access_audit_events (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, actor_user_id TEXT NOT NULL REFERENCES gwf_users(id), action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_access_audit_created ON gwf_access_audit_events(organization_id,created_at)`,
}
for _, statement := range statements {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return err
}
}
hasPasswordRequirement, err := sqliteColumnExists(ctx, tx, "gwf_users", "password_change_required")
if err != nil {
return err
}
if !hasPasswordRequirement {
if _, err = tx.ExecContext(ctx, `ALTER TABLE gwf_users ADD COLUMN password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1))`); err != nil {
return err
}
}
for _, migration := range []struct {
table, column, definition string
}{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
{"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`},
{"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`},
{"gwf_teams", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_teams", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_teams", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
{"gwf_organization_invitations", "id", `TEXT`},
{"gwf_organization_invitations", "revoked_at", `INTEGER`},
{"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`},
{"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`},
{"gwf_organization_invitations", "direct_roles_json", `BLOB NOT NULL DEFAULT '[]'`},
{"gwf_organization_invitations", "required_owner_role", `TEXT NOT NULL DEFAULT ''`},
} {
exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
if columnErr != nil {
return columnErr
}
if !exists {
if _, err = tx.ExecContext(ctx, `ALTER TABLE `+migration.table+` ADD COLUMN `+migration.column+` `+migration.definition); err != nil {
return err
}
}
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organizations SET updated_at=created_at WHERE updated_at=0`); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_teams SET updated_at=created_at WHERE updated_at=0`); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET id=lower(hex(token_hash)) WHERE id IS NULL`); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS gwf_organization_invitations_id ON gwf_organization_invitations(id)`); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(2,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(3,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
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
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
func sqliteColumnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var position, notNull, primaryKey int
var name, kind string
var defaultValue sql.NullString
if err = rows.Scan(&position, &name, &kind, &notNull, &defaultValue, &primaryKey); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error {
if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) {
return errors.New("authsqlite: invalid user")
@@ -109,7 +280,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,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 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
}
@@ -125,18 +296,103 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
}
var user auth.User
var created, updated int64
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.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, &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
}
if err != nil {
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
}
func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth.User, string, error) {
if !opaqueID(userID) {
return auth.User{}, "", auth.ErrUserNotFound
}
var user auth.User
var created, updated int64
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.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
}
if err != nil {
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
}
func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
if !opaqueID(userID) || when.IsZero() {
return errors.New("authsqlite: invalid login update")
@@ -160,10 +416,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
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,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, &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
}
@@ -271,13 +530,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
return err
}
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() {
if !validAuditEvent(event) {
return errors.New("authsqlite: invalid audit event")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
return appendAudit(ctx, store.db, event)
}
type auditExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
return err
}
func validAuditEvent(event auth.AuditEvent) bool {
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
}
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func safeName(value string) bool {
if value == "" || len(value) > 128 {
+727
View File
@@ -3,6 +3,8 @@
package authsqlite
import (
"database/sql"
"errors"
"os"
"path/filepath"
"runtime"
@@ -10,9 +12,29 @@ import (
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"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 {
@@ -53,6 +75,171 @@ func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
}
}
func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(3000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "bootstrap", Email: "bootstrap@example.test", DisplayName: "Bootstrap Operator", Password: "temporary bootstrap credential", RequirePasswordChange: true})
if err != nil || !user.PasswordChangeRequired {
t.Fatalf("user=%+v err=%v", user, err)
}
token, principal, err := service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
if err = service.ChangePassword(t.Context(), user.ID, "wrong current credential", "new permanent credential"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("wrong current credential err=%v", err)
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("failed rotation revoked session: %v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "temporary bootstrap credential"); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("reused credential err=%v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "new permanent credential"); err != nil {
t.Fatal(err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived rotation: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("temporary credential survived rotation: %v", err)
}
_, principal, err = service.Authenticate(t.Context(), user.Username, "new permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("rotated principal=%+v err=%v", principal, err)
}
}
func TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(4000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"})
if err != nil || !reset.PasswordChangeRequired {
t.Fatalf("reset=%+v err=%v", reset, err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived reset: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old credential survived reset: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("recovery principal=%+v err=%v", principal, err)
}
var action, summary string
var events int
if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil {
t.Fatal(err)
}
if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") {
t.Fatalf("events=%d action=%q summary=%q", events, action, summary)
}
if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("same credential err=%v", err)
}
}
func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(5000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
newHash, err := auth.HashPassword("one-time recovery credential")
if err != nil {
t.Fatal(err)
}
audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now}
if err = store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil {
t.Fatal("duplicate audit unexpectedly committed reset")
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("rollback revoked session: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("uncommitted recovery credential accepted: %v", err)
}
}
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
database, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = database.Exec(`CREATE TABLE 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, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`)
if err == nil {
_, err = database.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('existing-user','existing','existing','existing@example.test','existing@example.test','Existing','active',1,1)`)
}
if closeErr := database.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatal(err)
}
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
var required, migrations int
if err = store.db.QueryRow(`SELECT password_change_required FROM gwf_users WHERE id='existing-user'`).Scan(&required); err != nil || required != 0 {
t.Fatalf("required=%d err=%v", required, err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gamertan_web_migrations WHERE version=3`).Scan(&migrations); err != nil || migrations != 1 {
t.Fatalf("migrations=%d err=%v", migrations, err)
}
}
func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
store, err := Open(path)
@@ -115,3 +302,543 @@ func TestOpenRejectsSymlinkDatabase(t *testing.T) {
t.Fatal("symlink database accepted")
}
}
func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(2000, 0).UTC()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "owner.one", Email: "owner@example.test", DisplayName: "Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.one", Email: "member@example.test", DisplayName: "Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "observatory-test", Name: "Observatory Test", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID, owner.ID); err != nil {
t.Fatal(err)
}
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"})
if err != nil {
t.Fatal(err)
}
environment, err := organizationService.CreateEnvironment(t.Context(), organizations.CreateEnvironment{OrganizationID: organization.ID, ProjectID: project.ID, Slug: "production", Name: "Production"})
if err != nil {
t.Fatal(err)
}
application, err := organizationService.CreateApplicationService(t.Context(), organizations.CreateApplicationService{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, Slug: "web", Name: "Web"})
if err != nil {
t.Fatal(err)
}
policy := access.Policy{Roles: map[string]string{"viewer": "Read telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
scope := access.Scope{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, ServiceID: application.ID}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.Team, SubjectID: team.ID, Role: "viewer", Scope: scope, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
decision, err := accessService.Authorize(t.Context(), member.ID, scope, "telemetry.read")
if err != nil || !decision.Allowed || decision.Source != "role" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read")
if err != nil || decision.Allowed {
t.Fatalf("sensitive decision=%+v err=%v", decision, err)
}
if _, err = accessService.ActivateBreakGlass(t.Context(), organization.ID, member.ID, "telemetry.sensitive.read", "Investigate the active production incident", "request-12345678", 15*time.Minute); err != nil {
t.Fatal(err)
}
decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read")
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
}
var audits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 6 {
t.Fatalf("audits=%d err=%v", audits, err)
}
auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10)
if err != nil || len(auditEvents) != 6 {
t.Fatalf("audit events=%+v err=%v", auditEvents, err)
}
foundBreakGlass := false
for _, event := range auditEvents {
foundBreakGlass = foundBreakGlass || event.Action == "break_glass.activate"
}
if !foundBreakGlass {
t.Fatalf("break-glass audit missing: %+v", auditEvents)
}
}
func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "owner.lifecycle", Email: "owner-lifecycle@example.test", DisplayName: "Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.lifecycle", Email: "member-lifecycle@example.test", DisplayName: "Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "organization.owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "lifecycle-test", Name: "Lifecycle Test", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
policy := access.Policy{Roles: map[string]string{"organization.owner": "Owner"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"organization.owner": {"telemetry.read"}}}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "organization.owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-last-owner"}); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("last-owner suspension err=%v", err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, invitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "organization.owner", TeamIDs: []string{team.ID}, Lifetime: 7 * 24 * time.Hour})
if err != nil {
t.Fatal(err)
}
if invitation.DirectRole != "organization.owner" || len(invitation.TeamIDs) != 1 {
t.Fatalf("invitation=%+v", invitation)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || !decision.Allowed {
t.Fatalf("member decision=%+v err=%v", decision, err)
}
teams, err := organizationService.Teams(t.Context(), organization.ID, member.ID)
if err != nil || len(teams) != 1 || teams[0].ID != team.ID {
t.Fatalf("member teams=%+v err=%v", teams, err)
}
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: "request-suspend-owner"}); err != nil {
t.Fatal(err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-last-member"}); !errors.Is(err, organizations.ErrLastOwner) {
t.Fatalf("sole active owner removal err=%v", err)
}
if _, err = organizationService.SetOrganizationStatus(t.Context(), organizations.SetOrganizationStatus{ID: organization.ID, Status: "archived", ActorUserID: member.ID, ExpectedRevision: organization.Revision, RequestID: "request-archive"}); err != nil {
t.Fatal(err)
}
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || decision.Allowed {
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
}
}
func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"},
Permissions: map[string]string{"site.view": "View site"},
Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
if err != nil {
t.Fatal(err)
}
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
members, err := organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") {
t.Fatalf("members=%+v err=%v", members, err)
}
direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil || len(direct) != 2 {
t.Fatalf("direct=%+v err=%v", direct, err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-self-promote", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner self-promotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-demote-owner", ExpectedBindingIDs: []string{ownerBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-demotion err=%v", err)
}
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: member.ID, RequestID: "request-suspend-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-remove-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner-removal err=%v", err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", member.ID, "request-legacy-suspend-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-suspension err=%v", err)
}
if err = organizationService.RemoveMembership(t.Context(), organization.ID, owner.ID, member.ID, "request-legacy-remove-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy non-owner owner-removal err=%v", err)
}
type replacementResult struct {
binding access.Binding
err error
}
start := make(chan struct{})
results := make(chan replacementResult, 2)
for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} {
requestID := requestID
go func() {
<-start
binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}})
results <- replacementResult{binding: binding, err: replaceErr}
}()
}
close(start)
var memberOwner access.Binding
var successful, conflicted int
for range 2 {
result := <-results
switch {
case result.err == nil:
successful++
memberOwner = result.binding
case errors.Is(result.err, access.ErrRoleChangeConflict):
conflicted++
default:
t.Fatalf("concurrent replacement err=%v", result.err)
}
}
if successful != 1 || conflicted != 1 {
t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) {
t.Fatalf("stale replacement err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged replacement err=%v", err)
}
ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}})
if err != nil {
t.Fatal(err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last-owner demotion err=%v", err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil {
t.Fatal(err)
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil {
t.Fatal(err)
}
members, err = organizationService.Members(t.Context(), organization.ID, 10)
if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") {
t.Fatalf("suspended members=%+v err=%v", members, err)
}
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil {
t.Fatal("suspended member role was replaced")
}
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil {
t.Fatal(err)
}
duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now}
if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil {
t.Fatal(err)
}
replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now}
if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil {
t.Fatal("audit failure did not roll back role replacement")
}
direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10)
if err != nil {
t.Fatal(err)
}
var memberRoles []string
for _, binding := range direct {
if binding.SubjectID == member.ID {
memberRoles = append(memberRoles, binding.ID+":"+binding.Role)
}
}
if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" {
t.Fatalf("rollback member roles=%v", memberRoles)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
}
func TestOwnerInvitationsRequireDirectOwnerAuthority(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
Permissions: map[string]string{"site.access.manage": "Manage site access"},
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation err=%v", err)
}
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
if err != nil {
t.Fatalf("non-owner ordinary invitation err=%v", err)
}
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
if err != nil {
t.Fatalf("owner invitation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
t.Fatalf("ordinary invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
t.Fatalf("owner invitation revocation err=%v", err)
}
}
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.owner", Email: "lifecycle-owner@example.test", DisplayName: "Lifecycle Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "lifecycle.member", Email: "lifecycle-member@example.test", DisplayName: "Lifecycle Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "optimistic-lifecycle", Name: "Optimistic Lifecycle", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
policy := access.Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"owner": {"telemetry.read"}, "viewer": {"telemetry.read"}}}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "viewer", TeamIDs: []string{team.ID}, Lifetime: 24 * time.Hour})
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
for _, requestID := range []string{"request-suspend-one", "request-suspend-two"} {
requestID := requestID
go func() {
<-start
results <- organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: owner.ID, RequestID: requestID})
}()
}
close(start)
var successful, conflicted int
for range 2 {
switch lifecycleErr := <-results; {
case lifecycleErr == nil:
successful++
case errors.Is(lifecycleErr, organizations.ErrRevisionConflict):
conflicted++
default:
t.Fatalf("concurrent membership suspension err=%v", lifecycleErr)
}
}
if successful != 1 || conflicted != 1 {
t.Fatalf("concurrent membership suspension success=%d conflict=%d", successful, conflicted)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action='membership.suspended' AND resource_id=?`, member.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || decision.Allowed {
t.Fatalf("suspended member decision=%+v err=%v", decision, err)
}
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-stale-remove"}); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale membership removal err=%v", err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-remove", 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 1)
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-reactivate"}); err != nil {
t.Fatal(err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, member.ID, 0)
if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "suspended", Status: "active", ActorUserID: owner.ID, RequestID: "request-stale-reactivate"}); !errors.Is(err, organizations.ErrRevisionConflict) {
t.Fatalf("stale membership reactivation err=%v", err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-stale-reactivate", 0)
if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: member.ID, ExpectedStatus: "active", ActorUserID: owner.ID, RequestID: "request-remove-member"}); err != nil {
t.Fatal(err)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, member.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NOT NULL`, member.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE request_id=?`, "request-remove-member", 1)
decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read")
if err != nil || decision.Allowed {
t.Fatalf("removed member decision=%+v err=%v", decision, err)
}
}
func membershipPresent(values []organizations.Membership, userID, status string) bool {
for _, value := range values {
if value.UserID == userID && value.Status == status {
return true
}
}
return false
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey := privateKey.PublicKey().Bytes()
encoded, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.EllipticKey),
3: int64(webauthncose.AlgES256),
-1: int64(webauthncose.P256),
-2: publicKey[1:33],
-3: publicKey[33:65],
})
if err != nil {
t.Fatal(err)
}
credential := &wa.Credential{
PublicKey: encoded,
// This value is absent when a standards-compliant client serializes the
// mandatory attestation object without optional response conveniences.
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
}
if err = enforceCredentialAlgorithm(credential); err != nil {
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
}
}
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
rsaKey, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.RSAKey),
3: int64(webauthncose.AlgRS256),
-1: []byte{0xff},
-2: []byte{0x01, 0x00, 0x01},
})
if err != nil {
t.Fatal(err)
}
for name, credential := range map[string]*wa.Credential{
"nil": nil,
"malformed": {PublicKey: []byte("not-cose")},
"rsa": {PublicKey: rsaKey},
} {
t.Run(name, func(t *testing.T) {
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
t.Fatalf("error=%v", err)
}
})
}
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
func FuzzPasskeyResponseParsers(f *testing.F) {
f.Add([]byte(`{}`))
f.Add([]byte(`{"id":"credential","rawId":"Y3JlZGVudGlhbA","type":"public-key","response":{}}`))
f.Fuzz(func(t *testing.T, value []byte) {
if len(value) > maxResponseBytes {
t.Skip()
}
_, _ = protocol.ParseCredentialCreationResponseBytes(value)
_, _ = protocol.ParseCredentialRequestResponseBytes(value)
})
}
+786
View File
@@ -0,0 +1,786 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
)
const (
defaultEnrollmentLifetime = 15 * time.Minute
defaultRegistrationTTL = 5 * time.Minute
defaultLoginTTL = 2 * time.Minute
defaultApprovalTTL = 90 * time.Second
maxCredentialLabelBytes = 80
maxResponseBytes = 128 << 10
)
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
type Config struct {
RPID string
RPDisplayName string
Origin string
// AllowDevelopmentPort permits an explicit non-default HTTPS port only
// for localhost or a reserved .test relying-party ID. Production origins
// remain portless, while local applications can terminate trusted HTTPS
// without requiring a privileged listener.
AllowDevelopmentPort bool
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
ApprovalTTL time.Duration
SessionLifetime time.Duration
RequiredCredentialCount int
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
auth *auth.Service
webAuthn *wa.WebAuthn
random io.Reader
now func() time.Time
config Config
}
type BootstrapInput struct {
Username, Email, DisplayName string
}
func New(repository Repository, authService *auth.Service, config Config) (*Service, error) {
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
return nil, errors.New("authwebauthn: relying-party display name is invalid")
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Now == nil {
config.Now = time.Now
}
if config.EnrollmentLifetime == 0 {
config.EnrollmentLifetime = defaultEnrollmentLifetime
}
if config.RegistrationTTL == 0 {
config.RegistrationTTL = defaultRegistrationTTL
}
if config.LoginTTL == 0 {
config.LoginTTL = defaultLoginTTL
}
if config.ApprovalTTL == 0 {
config.ApprovalTTL = defaultApprovalTTL
}
if config.SessionLifetime == 0 {
config.SessionLifetime = 12 * time.Hour
}
if config.RequiredCredentialCount == 0 {
config.RequiredCredentialCount = 2
}
if config.EnrollmentLifetime < time.Minute || config.EnrollmentLifetime > time.Hour ||
config.RegistrationTTL < time.Minute || config.RegistrationTTL > 10*time.Minute ||
config.LoginTTL < time.Minute || config.LoginTTL > 5*time.Minute ||
config.ApprovalTTL < 30*time.Second || config.ApprovalTTL > 2*time.Minute ||
config.SessionLifetime < 5*time.Minute || config.SessionLifetime > 30*24*time.Hour ||
config.RequiredCredentialCount < 1 || config.RequiredCredentialCount > 8 {
return nil, errors.New("authwebauthn: lifetime or credential-count policy is invalid")
}
webAuthn, err := wa.New(&wa.Config{
RPID: config.RPID,
RPDisplayName: config.RPDisplayName,
RPOrigins: []string{config.Origin},
RPAllowCrossOrigin: false,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationRequired,
},
Timeouts: wa.TimeoutsConfig{
Login: wa.TimeoutConfig{Timeout: config.LoginTTL, TimeoutUVD: config.LoginTTL},
Registration: wa.TimeoutConfig{Timeout: config.RegistrationTTL, TimeoutUVD: config.RegistrationTTL},
},
})
if err != nil {
return nil, fmt.Errorf("authwebauthn: configure verifier: %w", err)
}
return &Service{repository: repository, auth: authService, webAuthn: webAuthn, random: config.Random, now: config.Now, config: config}, nil
}
func (service *Service) Bootstrap(ctx context.Context, input BootstrapInput) (auth.User, string, error) {
username := strings.TrimSpace(input.Username)
email := strings.TrimSpace(input.Email)
displayName := strings.TrimSpace(input.DisplayName)
if !accountNamePattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 {
return auth.User{}, "", errors.New("authwebauthn: invalid user")
}
userID, err := service.randomToken(18)
if err != nil {
return auth.User{}, "", err
}
token, enrollment, err := service.newEnrollment(userID)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
audit, err := service.audit("", "auth.passkey.bootstrap", "user", userID, "A local administrator created a passkey-only account and one-time enrollment token.")
if err != nil {
return auth.User{}, "", err
}
if err = service.repository.CreatePasskeyUser(ctx, user, enrollment, audit); err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) Recover(ctx context.Context, identifier, reason string) (auth.User, string, error) {
identifier = strings.TrimSpace(identifier)
reason = strings.TrimSpace(reason)
if identifier == "" || len(identifier) > 320 || reason == "" || len(reason) > 240 || strings.ContainsAny(reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authwebauthn: recovery identifier and bounded reason are required")
}
user, err := service.repository.UserByIdentifier(ctx, identifier)
if err != nil {
return auth.User{}, "", err
}
if user.Status != "active" {
return auth.User{}, "", auth.ErrInactiveUser
}
token, enrollment, err := service.newEnrollment(user.ID)
if err != nil {
return auth.User{}, "", err
}
audit, err := service.audit("", "auth.passkey.recovery", "user", user.ID, "A local administrator revoked sessions and issued a one-time passkey enrollment token. Reason: "+reason)
if err != nil {
return auth.User{}, "", err
}
user, err = service.repository.RecoverUser(ctx, identifier, enrollment, audit)
if err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, label string) (BeginResult, error) {
digest, err := tokenDigest(enrollmentToken)
if err != nil {
return BeginResult{}, ErrEnrollmentNotFound
}
user, err := service.repository.ConsumeEnrollmentToken(ctx, digest, service.now().UTC())
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
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
// password-backed user. Completion atomically retires the password and revokes
// all sessions, including the session that authorized this ceremony.
func (service *Service) BeginPasswordMigration(ctx context.Context, userID, label string) (BeginResult, error) {
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
exists, err := service.repository.PasswordCredentialExists(ctx, user.ID)
if err != nil {
return BeginResult{}, err
}
if !exists {
return BeginResult{}, ErrPasswordNotAvailable
}
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, allowPending bool) (BeginResult, error) {
label, err := credentialLabel(label)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
creation, session, err := service.webAuthn.BeginRegistration(adapter,
func(options *protocol.PublicKeyCredentialCreationOptions) { options.Challenge = challenge },
wa.WithCredentialParameters([]protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}),
wa.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
wa.WithConveyancePreference(protocol.PreferNoAttestation),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err)
}
return service.storeCeremony(ctx, kind, user.ID, label, session, binding, creation.Response, service.config.RegistrationTTL)
}
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)
}
// 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
// with password retirement and session revocation in one storage transaction.
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration)
if err != nil {
return Credential{}, err
}
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
}
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)
}
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
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
user, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Credential{}, err
}
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return Credential{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Credential{}, err
}
parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
if err != nil {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
verified, err := service.webAuthn.CreateCredential(adapter, session, parsed)
if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
}
if err = enforceCredentialAlgorithm(verified); err != nil {
return Credential{}, ErrUnsupportedCredential
}
encoded, err := json.Marshal(verified)
if err != nil {
return Credential{}, err
}
now := service.now().UTC()
record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now}
action, summary := "auth.passkey.add", "A passkey was enrolled."
if retirePassword {
action, summary = "auth.passkey.migrate", "A passkey was enrolled and the legacy password credential was retired."
}
audit, err := service.audit(user.ID, action, "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), summary)
if err != nil {
return Credential{}, err
}
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)
}
if err != nil {
return Credential{}, err
}
return record, nil
}
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
// carried inside authenticator data. AuthenticatorAttestationResponse's
// publicKeyAlgorithm member is an optional browser convenience value: clients
// that serialize the mandatory attestation object directly may omit it, and it
// is not the cryptographically authoritative representation.
func enforceCredentialAlgorithm(credential *wa.Credential) error {
if credential == nil {
return ErrUnsupportedCredential
}
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
if err != nil {
return ErrUnsupportedCredential
}
key, ok := parsed.(webauthncose.EC2PublicKeyData)
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
return ErrUnsupportedCredential
}
return nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginDiscoverableLogin(
wa.WithChallenge(challenge),
wa.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin login: %w", err)
}
return service.storeCeremony(ctx, CeremonyLogin, "", "", session, [32]byte{}, assertion.Response, service.config.LoginTTL)
}
func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, response []byte) (Authentication, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyLogin)
if err != nil {
return Authentication{}, err
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Authentication{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
var loaded *passkeyUser
user, verified, err := service.webAuthn.ValidatePasskeyLogin(func(rawID, userHandle []byte) (wa.User, error) {
account, lookupErr := service.repository.UserByCredentialID(ctx, rawID)
if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound
}
loaded, lookupErr = service.user(ctx, account, false)
return loaded, lookupErr
}, session, parsed)
if err != nil || loaded == nil || user == nil {
return Authentication{}, errors.New("authwebauthn: authentication failed")
}
if err = service.persistUsedCredential(ctx, loaded.account.ID, verified); err != nil {
return Authentication{}, err
}
token, principal, err := service.auth.IssueSession(ctx, loaded.account.ID, service.config.SessionLifetime)
if err != nil {
return Authentication{}, err
}
return Authentication{SessionToken: token, Principal: principal, CredentialID: append([]byte(nil), verified.ID...), CloneWarning: verified.Authenticator.CloneWarning}, nil
}
func (service *Service) BeginApproval(ctx context.Context, userID string, binding []byte) (BeginResult, error) {
if len(binding) < 32 || len(binding) > 32<<10 {
return BeginResult{}, errors.New("authwebauthn: operation binding is invalid")
}
if err := service.RequireReady(ctx, userID); err != nil {
return BeginResult{}, err
}
account, err := service.repository.UserByID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, account, false)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginLogin(adapter, wa.WithChallenge(challenge), wa.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin approval: %w", err)
}
return service.storeCeremony(ctx, CeremonyApproval, account.ID, "", session, BindingDigest(binding), assertion.Response, service.config.ApprovalTTL)
}
func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string, binding, response []byte) (Approval, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyApproval)
if err != nil {
return Approval{}, err
}
if BindingDigest(binding) != ceremony.BindingDigest {
return Approval{}, ErrOperationBinding
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
account, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Approval{}, err
}
adapter, err := service.user(ctx, account, false)
if err != nil {
return Approval{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Approval{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
verified, err := service.webAuthn.ValidateLogin(adapter, session, parsed)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval failed")
}
if err = service.persistUsedCredential(ctx, account.ID, verified); err != nil {
return Approval{}, err
}
return Approval{User: account, CredentialID: append([]byte(nil), verified.ID...), BindingDigest: ceremony.BindingDigest, CloneWarning: verified.Authenticator.CloneWarning, ApprovedAt: service.now().UTC()}, nil
}
func (service *Service) RequireReady(ctx context.Context, userID string) error {
count, err := service.repository.CredentialCount(ctx, userID)
if err != nil {
return err
}
if count < service.config.RequiredCredentialCount {
return ErrPasskeyReadiness
}
return nil
}
// RequiredCredentialCount reports the configured operational credential
// floor. Applications can use it to explain rotation policy without
// duplicating security configuration.
func (service *Service) RequiredCredentialCount() int {
return service.config.RequiredCredentialCount
}
// CredentialSummaries returns bounded account-owner metadata without exposing
// stored credential documents.
func (service *Service) CredentialSummaries(ctx context.Context, userID string) ([]CredentialSummary, error) {
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return nil, err
}
summaries := make([]CredentialSummary, 0, len(records))
for _, record := range records {
summaries = append(summaries, CredentialSummary{
ID: append([]byte(nil), record.ID...),
Label: record.Label,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
})
}
return summaries, nil
}
func (service *Service) BeginCredentialRemoval(ctx context.Context, userID string, credentialID []byte) (BeginResult, error) {
if len(credentialID) < 16 || len(credentialID) > 1024 {
return BeginResult{}, ErrCredentialNotFound
}
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
found := false
for _, record := range records {
if bytes.Equal(record.ID, credentialID) {
found = true
break
}
}
if !found {
return BeginResult{}, ErrCredentialNotFound
}
if len(records) <= service.config.RequiredCredentialCount {
return BeginResult{}, ErrCredentialFloor
}
return service.BeginApproval(ctx, userID, credentialRemovalBinding(userID, credentialID))
}
func (service *Service) FinishCredentialRemoval(ctx context.Context, ceremonyToken, userID string, credentialID, response []byte) error {
binding := credentialRemovalBinding(userID, credentialID)
approval, err := service.FinishApproval(ctx, ceremonyToken, binding, response)
if err != nil {
return err
}
if approval.User.ID != userID || approval.BindingDigest != BindingDigest(binding) {
return ErrOperationBinding
}
audit, err := service.audit(userID, "auth.passkey.remove", "passkey", base64.RawURLEncoding.EncodeToString(credentialID), "A passkey was removed after fresh authentication.")
if err != nil {
return err
}
return service.repository.DeleteCredential(ctx, userID, credentialID, service.config.RequiredCredentialCount, audit)
}
func (service *Service) storeCeremony(ctx context.Context, kind, userID, label string, session *wa.SessionData, binding [32]byte, publicKey any, ttl time.Duration) (BeginResult, error) {
token, err := service.randomToken(32)
if err != nil {
return BeginResult{}, err
}
digest := sha256.Sum256([]byte(token))
now := service.now().UTC()
session.Expires = now.Add(ttl)
sessionJSON, err := json.Marshal(session)
if err != nil {
return BeginResult{}, err
}
publicJSON, err := json.Marshal(publicKey)
if err != nil {
return BeginResult{}, err
}
ceremony := Ceremony{Digest: digest, Kind: kind, UserID: userID, Label: label, SessionData: sessionJSON, BindingDigest: binding, CreatedAt: now, ExpiresAt: now.Add(ttl)}
if err = service.repository.CreateCeremony(ctx, ceremony); err != nil {
return BeginResult{}, err
}
return BeginResult{CeremonyToken: token, PublicKey: publicJSON, ExpiresAt: ceremony.ExpiresAt}, nil
}
func (service *Service) takeCeremony(ctx context.Context, token, kind string) (Ceremony, error) {
digest, err := tokenDigest(token)
if err != nil {
return Ceremony{}, ErrCeremonyNotFound
}
ceremony, err := service.repository.TakeCeremony(ctx, digest, service.now().UTC())
if err != nil {
return Ceremony{}, err
}
if ceremony.Kind != kind {
return Ceremony{}, ErrCeremonyNotFound
}
return ceremony, nil
}
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)
if err != nil {
return nil, err
}
credentials := make([]wa.Credential, 0, len(records))
for _, record := range records {
var credential wa.Credential
if err = json.Unmarshal(record.Data, &credential); err != nil || len(credential.ID) == 0 {
return nil, errors.New("authwebauthn: stored credential is invalid")
}
credentials = append(credentials, credential)
}
return &passkeyUser{account: account, credentials: credentials}, nil
}
func (service *Service) persistUsedCredential(ctx context.Context, userID string, credential *wa.Credential) error {
encoded, err := json.Marshal(credential)
if err != nil {
return err
}
return service.repository.UpdateCredential(ctx, Credential{ID: append([]byte(nil), credential.ID...), UserID: userID, Data: encoded, LastUsedAt: service.now().UTC()})
}
func (service *Service) newEnrollment(userID string) (string, EnrollmentToken, error) {
token, err := service.randomToken(32)
if err != nil {
return "", EnrollmentToken{}, err
}
now := service.now().UTC()
return token, EnrollmentToken{Digest: sha256.Sum256([]byte(token)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.config.EnrollmentLifetime)}, nil
}
func (service *Service) audit(actor, action, resourceType, resourceID, summary string) (auth.AuditEvent, error) {
id, err := service.randomToken(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) randomToken(size int) (string, error) {
value, err := service.randomBytes(size)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (service *Service) randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return nil, fmt.Errorf("authwebauthn: secure randomness unavailable: %w", err)
}
return value, nil
}
func tokenDigest(token string) ([32]byte, error) {
if len(token) < 32 || len(token) > 128 {
return [32]byte{}, errors.New("invalid token")
}
if _, err := base64.RawURLEncoding.DecodeString(token); err != nil {
return [32]byte{}, errors.New("invalid token")
}
return sha256.Sum256([]byte(token)), nil
}
func decodeSession(value []byte) (wa.SessionData, error) {
var session wa.SessionData
if len(value) == 0 || len(value) > 64<<10 || json.Unmarshal(value, &session) != nil {
return wa.SessionData{}, errors.New("authwebauthn: stored ceremony is invalid")
}
return session, nil
}
func credentialLabel(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || len(value) > maxCredentialLabelBytes || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("authwebauthn: credential label is invalid")
}
return value, nil
}
func credentialRemovalBinding(userID string, credentialID []byte) []byte {
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
}
func passwordMigrationBinding(userID string) [32]byte {
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
}
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
port := origin.Port()
if port == "" {
if origin.Host != rpID {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test")
value, portErr := strconv.ParseUint(port, 10, 16)
if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
type passkeyUser struct {
account auth.User
credentials []wa.Credential
}
func (user *passkeyUser) WebAuthnID() []byte { return []byte(user.account.ID) }
func (user *passkeyUser) WebAuthnName() string { return user.account.Username }
func (user *passkeyUser) WebAuthnDisplayName() string { return user.account.DisplayName }
func (user *passkeyUser) WebAuthnCredentials() []wa.Credential { return user.credentials }
+293
View File
@@ -0,0 +1,293 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"io"
"testing"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
)
func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, enrollmentToken, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "operator.one", Email: "operator@example.test", DisplayName: "Operator One"})
if err != nil {
t.Fatal(err)
}
if enrollmentToken == "" || user.PasswordChangeRequired {
t.Fatalf("unexpected bootstrap user=%+v token=%q", user, enrollmentToken)
}
begin, err := service.BeginEnrollment(t.Context(), enrollmentToken, "Primary passkey")
if err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), enrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("replayed enrollment err=%v", err)
}
var options protocol.PublicKeyCredentialCreationOptions
if err = json.Unmarshal(begin.PublicKey, &options); err != nil {
t.Fatal(err)
}
if options.RelyingParty.ID != "tend.gamertan.com" || options.AuthenticatorSelection.UserVerification != protocol.VerificationRequired || options.AuthenticatorSelection.ResidentKey != protocol.ResidentKeyRequirementRequired || options.Attestation != protocol.PreferNoAttestation {
t.Fatalf("unexpected registration policy: %+v", options)
}
if len(options.Parameters) != 1 || options.Parameters[0].Algorithm != webauthncose.AlgES256 {
t.Fatalf("unexpected algorithms: %+v", options.Parameters)
}
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)
}
for index := range 2 {
credential := wa.Credential{ID: bytes.Repeat([]byte{byte(index + 1)}, 32), PublicKey: []byte{1, 2, 3}}
encoded, marshalErr := json.Marshal(credential)
if marshalErr != nil {
t.Fatal(marshalErr)
}
audit := auth.AuditEvent{ID: "audit-passkey-" + string(rune('a'+index)), ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: "fixture", Summary: "fixture", CreatedAt: now}
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Fixture", Data: encoded, CreatedAt: now}, audit); err != nil {
t.Fatal(err)
}
}
if err = service.RequireReady(t.Context(), user.ID); err != nil {
t.Fatal(err)
}
summaries, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
if len(summaries) != 2 || summaries[0].Label != "Fixture" || len(summaries[0].ID) != 32 {
t.Fatalf("unexpected summaries: %+v", summaries)
}
summaries[0].ID[0] = 99
refreshed, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil || refreshed[0].ID[0] == 99 {
t.Fatalf("credential summary did not defensively copy the identifier: summaries=%+v err=%v", refreshed, err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, refreshed[0].ID); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential removal below operational floor err=%v", err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, bytes.Repeat([]byte{9}, 32)); !errors.Is(err, authwebauthn.ErrCredentialNotFound) {
t.Fatalf("unknown credential removal err=%v", err)
}
binding := bytes.Repeat([]byte("approved operation "), 3)
approvalBegin, err := service.BeginApproval(t.Context(), user.ID, binding)
if err != nil {
t.Fatal(err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("tampered binding err=%v", err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, binding, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("replayed approval err=%v", err)
}
login, err := service.BeginLogin(t.Context())
if err != nil {
t.Fatal(err)
}
now = now.Add(3 * time.Minute)
if _, err = service.FinishLogin(t.Context(), login.CeremonyToken, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("expired login err=%v", err)
}
_ = authService
}
func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, _, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recover Me"})
if err != nil {
t.Fatal(err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
_, token, err := service.Recover(t.Context(), "recover.me", "all authenticators unavailable")
if err != nil {
t.Fatal(err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Recovered passkey"); err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("recovery token replay err=%v", err)
}
}
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")
if err != nil {
t.Fatal(err)
}
defer store.Close()
authService, err := auth.New(store, auth.Options{Random: &counterReader{}, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "legacy.user", Email: "legacy@example.test", DisplayName: "Legacy User", Password: "legacy migration password"})
if err != nil {
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "observatory.test", RPDisplayName: "Observatory", Origin: "https://observatory.test", RequiredCredentialCount: 1, Random: &counterReader{}, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
begin, err := service.BeginPasswordMigration(t.Context(), user.ID, "Primary passkey")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte(begin.CeremonyToken))
ceremony, err := store.TakeCeremony(t.Context(), digest, now)
if err != nil {
t.Fatal(err)
}
if ceremony.Kind != authwebauthn.CeremonyRegistration || ceremony.BindingDigest == ([32]byte{}) || ceremony.UserID != user.ID {
t.Fatalf("unexpected migration ceremony: %+v", ceremony)
}
credential := wa.Credential{ID: bytes.Repeat([]byte{9}, 32), PublicKey: []byte{1, 2, 3}}
encoded, err := json.Marshal(credential)
if err != nil {
t.Fatal(err)
}
audit := auth.AuditEvent{ID: "migration-direct", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now}
if err = store.SaveCredentialAndRetirePassword(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Primary", Data: encoded, CreatedAt: now}, audit); err != nil {
t.Fatal(err)
}
if _, err = service.BeginPasswordMigration(t.Context(), user.ID, "Replay"); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) {
t.Fatalf("retired password migration err=%v", err)
}
}
func TestConfigurationAndEntropyFailures(t *testing.T) {
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
for _, config := range []authwebauthn.Config{
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true},
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
for _, config := range []authwebauthn.Config{
{RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true},
{RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true},
} {
configured, configureErr := authwebauthn.New(store, authService, config)
if configureErr != nil || configured == nil {
t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
}
if _, _, err = service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "entropy.fail", Email: "entropy@example.test", DisplayName: "Entropy"}); err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("entropy failure err=%v", err)
}
}
func newService(t *testing.T, now *time.Time, random io.Reader) (*authsqlite.Store, *auth.Service, *authwebauthn.Service) {
t.Helper()
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
return store, authService, service
}
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
type counterReader struct{ next byte }
func (reader *counterReader) Read(value []byte) (int, error) {
for index := range value {
reader.next++
value[index] = reader.next
}
return len(value), nil
}
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: MPL-2.0
// 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 (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"time"
"gamertan.com/web/auth"
)
var (
ErrCeremonyNotFound = errors.New("authwebauthn: ceremony not found")
ErrCredentialNotFound = errors.New("authwebauthn: credential not found")
ErrEnrollmentNotFound = errors.New("authwebauthn: enrollment token not found")
ErrCredentialFloor = errors.New("authwebauthn: the required credential floor cannot be crossed")
ErrLastCredential = errors.New("authwebauthn: the last credential cannot be removed remotely")
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
ErrPasswordNotAvailable = errors.New("authwebauthn: password migration is not available")
)
const (
CeremonyRegistration = "registration"
CeremonyLogin = "login"
CeremonyApproval = "approval"
)
type Credential struct {
ID []byte
UserID string
Label string
Data json.RawMessage
CreatedAt time.Time
LastUsedAt time.Time
}
// CredentialSummary is the non-secret credential metadata applications may
// show to an authenticated account owner. It intentionally excludes the
// stored public-key document and user identifier.
type CredentialSummary struct {
ID []byte
Label string
CreatedAt time.Time
LastUsedAt time.Time
}
type EnrollmentToken struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Ceremony struct {
Digest [32]byte
Kind string
UserID string
Label string
SessionData json.RawMessage
BindingDigest [32]byte
CreatedAt time.Time
ExpiresAt time.Time
}
type BeginResult struct {
CeremonyToken string `json:"ceremony_token"`
PublicKey json.RawMessage `json:"public_key"`
ExpiresAt time.Time `json:"expires_at"`
}
type Authentication struct {
SessionToken string
Principal auth.Principal
CredentialID []byte
CloneWarning bool
}
type Approval struct {
User auth.User
CredentialID []byte
BindingDigest [32]byte
CloneWarning bool
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.
type Repository interface {
CreatePasskeyUser(context.Context, auth.User, EnrollmentToken, auth.AuditEvent) error
UserByID(context.Context, string) (auth.User, error)
UserByIdentifier(context.Context, string) (auth.User, error)
UserByCredentialID(context.Context, []byte) (auth.User, error)
CredentialsByUserID(context.Context, string) ([]Credential, error)
PasswordCredentialExists(context.Context, string) (bool, error)
SaveCredential(context.Context, Credential, auth.AuditEvent) error
SaveCredentialAndRetirePassword(context.Context, Credential, auth.AuditEvent) error
UpdateCredential(context.Context, Credential) error
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error
CredentialCount(context.Context, string) (int, error)
CreateCeremony(context.Context, Ceremony) error
TakeCeremony(context.Context, [32]byte, time.Time) (Ceremony, error)
ConsumeEnrollmentToken(context.Context, [32]byte, time.Time) (auth.User, error)
RecoverUser(context.Context, string, EnrollmentToken, auth.AuditEvent) (auth.User, error)
}
func BindingDigest(value []byte) [32]byte { return sha256.Sum256(value) }
+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)
}
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: MPL-2.0
// Package cms supplies bounded classification and editorial relationship values.
// It does not define content types, layouts, authorization, or commerce policy.
// Applications own those decisions and publish exact immutable revisions.
package cms
import (
"errors"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
var (
ErrInvalid = errors.New("cms: invalid value")
ErrConflict = errors.New("cms: revision or slug conflict")
ErrNotFound = errors.New("cms: not found")
identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`)
slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
)
const MaxTerms = 24
const MaxLinks = 16
// Reference names an application-owned resource, never a mutable URL or title.
type Reference struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
func ValidID(value string) bool { return identifier.MatchString(value) }
func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) }
func ValidText(value string, max int) bool {
return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl)
}
func (r Reference) Validate() error {
if !kind.MatchString(r.Kind) || !ValidID(r.ID) {
return ErrInvalid
}
return nil
}
// Associations are an immutable revision's term memberships and explicit links.
// Reverse discovery reads the same links; callers must not store a second edge.
// Terms and Links are independently optional. The zero value is valid for a
// valid source; unclassified content never needs a placeholder taxonomy or link.
type Associations struct {
Terms []string `json:"terms,omitempty"`
Links []Reference `json:"links,omitempty"`
}
func (a Associations) Validate(source Reference) error {
if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks {
return ErrInvalid
}
terms := map[string]bool{}
for _, id := range a.Terms {
if !ValidID(id) || terms[id] {
return ErrInvalid
}
terms[id] = true
}
links := map[Reference]bool{}
for _, ref := range a.Links {
if ref.Validate() != nil || ref == source || links[ref] {
return ErrInvalid
}
links[ref] = true
}
return nil
}
// Taxonomy is an editor-named vocabulary, not a database-defined content type.
// Slug is fixed after creation; Name/Description and availability may change.
type Taxonomy struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Revision int64 `json:"revision"`
Active bool `json:"active"`
}
func (v Taxonomy) Validate() error {
if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
return ErrInvalid
}
return nil
}
// Term identity survives renaming and retirement. Retirement hides discovery;
// it does not rewrite old associations or imply removal of related resources.
type Term struct {
ID string `json:"id"`
TaxonomyID string `json:"taxonomy_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Revision int64 `json:"revision"`
Active bool `json:"active"`
}
func (v Term) Validate() error {
if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
return ErrInvalid
}
return nil
}
type ResourceVersion struct {
Reference
Revision int64 `json:"revision"`
}
type Page struct {
Items []ResourceVersion `json:"items"`
Next *Reference `json:"next,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MPL-2.0
package cms
import (
"encoding/json"
"strings"
"testing"
)
func TestAssociationsBoundsAndIdentity(t *testing.T) {
source := Reference{Kind: "project", ID: "project-one"}
valid := Associations{Terms: []string{"go"}, Links: []Reference{{Kind: "news", ID: "launch"}}}
if valid.Validate(source) != nil {
t.Fatal("valid association rejected")
}
for _, value := range []Associations{
{Terms: []string{"go", "go"}}, {Terms: []string{"../private"}},
{Links: []Reference{source}}, {Links: []Reference{{Kind: "news", ID: "launch"}, {Kind: "news", ID: "launch"}}},
{Links: []Reference{{Kind: "<script>", ID: "safe"}}},
{Terms: make([]string, MaxTerms+1)}, {Links: make([]Reference, MaxLinks+1)},
} {
if value.Validate(source) == nil {
t.Fatalf("accepted %#v", value)
}
}
}
func TestAssociationsAreOptional(t *testing.T) {
for _, source := range []Reference{{Kind: "news", ID: "article"}, {Kind: "project", ID: "project"}, {Kind: "policy", ID: "terms"}} {
for _, raw := range []string{`{}`, `{"terms":null,"links":null}`, `{"terms":[],"links":[]}`, `{"terms":["go"]}`, `{"links":[{"kind":"project","id":"other"}]}`} {
var value Associations
if err := json.Unmarshal([]byte(raw), &value); err != nil {
t.Fatal(err)
}
if err := value.Validate(source); err != nil {
t.Fatalf("optional associations rejected: %s: %v", raw, err)
}
}
}
}
func TestTaxonomyAndTermText(t *testing.T) {
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
if tax.Validate() != nil {
t.Fatal("valid taxonomy")
}
for _, name := range []string{"", " bad", "bad\nname", string([]byte{255}), strings.Repeat("a", 121)} {
v := tax
v.Name = name
if v.Validate() == nil {
t.Fatal("accepted invalid name")
}
}
term := Term{ID: "go", TaxonomyID: tax.ID, Slug: "go", Name: "Go", Revision: 1, Active: true}
if term.Validate() != nil {
t.Fatal("valid term")
}
term.Slug = "../go"
if term.Validate() == nil {
t.Fatal("unsafe slug")
}
}
func FuzzAssociations(f *testing.F) {
f.Add(`{"terms":["go"],"links":[{"kind":"news","id":"launch"}]}`)
f.Fuzz(func(t *testing.T, raw string) {
if len(raw) > 20000 {
return
}
var value Associations
if json.Unmarshal([]byte(raw), &value) != nil {
return
}
if value.Validate(Reference{Kind: "project", ID: "one"}) == nil {
b, e := json.Marshal(value)
if e != nil {
t.Fatal(e)
}
var again Associations
if json.Unmarshal(b, &again) != nil || again.Validate(Reference{Kind: "project", ID: "one"}) != nil {
t.Fatal("round trip")
}
}
})
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"encoding/json"
"gamertan.com/web/cms"
)
// PutRevision appends once. Unknown terms are rejected; retired terms remain
// usable when copying historical revisions. Editors decide whether to admit new
// retired-term assignments. Link targets are validated by the application: they
// can live in a different content/catalog store. A link grants no authority.
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
return cms.ErrInvalid
}
for _, id := range a.Terms {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
b, err := json.Marshal(a)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
if err = oneRow(result, err); err != nil {
return err
}
for _, id := range a.Terms {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
return err
}
}
for _, target := range a.Links {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
return err
}
}
return nil
}
// SetPublished selects an exact revision or zero to unpublish. Call inside the
// same transaction as the application's publication transition and audit. Draft
// saves do not call this function. Product availability is also checked by the
// consuming application; publication is not entitlement or payment authority.
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
return cms.ErrInvalid
}
if revision > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
return oneRow(result, err)
}
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
if ref.Validate() != nil || revision < 1 {
return cms.Associations{}, cms.ErrInvalid
}
var raw string
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
if err != nil {
return cms.Associations{}, notFound(err)
}
var a cms.Associations
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
return cms.Associations{}, cms.ErrInvalid
}
return a, nil
}
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision sql.NullInt64
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
if err != nil {
return 0, err
}
if !revision.Valid {
return 0, cms.ErrNotFound
}
return revision.Int64, nil
}
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision int64
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
return revision, notFound(err)
}
// Members returns published members of an active term and taxonomy. Pagination
// happens after publication filtering, with stable kind/ID cursors.
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
if !cms.ValidID(termID) {
return cms.Page{}, cms.ErrInvalid
}
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
}
// Related returns both directions of explicit relationships between published
// revisions. Shared taxonomy membership alone does not assert a relationship.
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
if ref.Validate() != nil {
return cms.Page{}, cms.ErrInvalid
}
query := `WITH edges AS (
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
UNION
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
}
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
return cms.Page{}, cms.ErrInvalid
}
if after != nil {
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
args = append(args, after.Kind, after.Kind, after.ID)
}
query += ` ORDER BY r.kind,r.id LIMIT ?`
args = append(args, limit+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return cms.Page{}, err
}
defer rows.Close()
p := cms.Page{Items: []cms.ResourceVersion{}}
for rows.Next() {
var v cms.ResourceVersion
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
return cms.Page{}, err
}
p.Items = append(p.Items, v)
}
if err := rows.Err(); err != nil {
return cms.Page{}, err
}
if len(p.Items) > limit {
p.Items = p.Items[:limit]
ref := p.Items[limit-1].Reference
p.Next = &ref
}
return p, nil
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite_test
import (
"context"
"database/sql"
"fmt"
"gamertan.com/web/cms"
"gamertan.com/web/cmssqlite"
_ "modernc.org/sqlite"
)
func Example() {
ctx := context.Background()
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
if err != nil {
panic(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
defer tx.Rollback()
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
panic(err)
}
const scope = "my-site"
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
ref := cms.Reference{Kind: "article", ID: "first-post"}
// The application authorizes the writer and stores its content/audit in this
// same transaction. Only publication advances the public association pointer.
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
panic(err)
}
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
panic(err)
}
if err = tx.Commit(); err != nil {
panic(err)
}
reader, err := cmssqlite.New(db, scope)
if err != nil {
panic(err)
}
page, err := reader.Members(ctx, "go", nil, 20)
if err != nil {
panic(err)
}
for _, item := range page.Items {
fmt.Println(item.Kind, item.ID, item.Revision)
}
// Output: article first-post 1
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MPL-2.0
// Package cmssqlite stores cms values in an application's SQLite transaction.
// Schema installation is explicit. Callers own database opening, migration
// versions, authorization and audits. Every mutation must use a caller-owned
// transaction, committing its domain change and audit together; never use a
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
package cmssqlite
import (
"context"
"database/sql"
"gamertan.com/web/cms"
)
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// Reader may use a database or read transaction. Writers below require *sql.Tx.
type Reader struct {
db Queryer
scope string
}
func New(db Queryer, scope string) (*Reader, error) {
if db == nil || !cms.ValidID(scope) {
return nil, cms.ErrInvalid
}
return &Reader{db: db, scope: scope}, nil
}
// CreateSchema must be called from the application's explicit migration.
// It never changes an existing publishing schema or starts a transaction.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"gamertan.com/web/cms"
_ "modernc.org/sqlite"
)
func fixture(t *testing.T) (*sql.DB, *Reader) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) })
r, e := New(db, "merchant")
if e != nil {
t.Fatal(e)
}
for _, scope := range []string{"merchant", "other"} {
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0)
})
}
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0)
})
return db, r
}
func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) {
t.Helper()
tx, e := db.BeginTx(context.Background(), nil)
if e != nil {
t.Fatal(e)
}
defer tx.Rollback()
err := fn(tx)
if !errors.Is(err, want) {
t.Fatalf("mutation error %v, want %v", err, want)
}
if err == nil {
if e = tx.Commit(); e != nil {
t.Fatal(e)
}
}
}
func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) {
t.Helper()
mutate(t, db, nil, func(tx *sql.Tx) error {
if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil {
return err
}
if publish {
return SetPublished(context.Background(), tx, "merchant", ref, rev)
}
return nil
})
}
func TestPublicationRelationshipsAndHistory(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
project := cms.Reference{Kind: "project", ID: "hime"}
news := cms.Reference{Kind: "news", ID: "launch"}
product := cms.Reference{Kind: "product", ID: "support"}
save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true)
save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false)
p, e := r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatalf("draft leak: %+v %v", p, e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) })
for _, ref := range []cms.Reference{project, news} {
p, e = r.Related(ctx, ref, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatalf("reverse missing: %+v %v", p, e)
}
}
save(t, db, product, 1, cms.Associations{}, true)
save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("draft replaced published graph", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) })
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("stale published edge", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("missing new edge", e)
}
old, e := r.Revision(ctx, news, 1)
if e != nil || old.Links[0] != project {
t.Fatal("history changed", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) })
p, e = r.Related(ctx, news, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished target leak", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished source discovery", e)
}
// Restoring an old association is a new revision, not an overwritten row.
save(t, db, news, 3, old, true)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 {
t.Fatal("restore", e)
}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) })
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) })
}
func TestPublishWithoutTaxonomyOrRelationships(t *testing.T) {
db, reader := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "project", ID: "unclassified"}
save(t, db, ref, 1, cms.Associations{}, true)
value, err := reader.Revision(ctx, ref, 1)
if err != nil || len(value.Terms) != 0 || len(value.Links) != 0 {
t.Fatalf("empty associations: %+v %v", value, err)
}
page, err := reader.Related(ctx, ref, nil, 10)
if err != nil || len(page.Items) != 0 {
t.Fatalf("unexpected related content: %+v %v", page, err)
}
save(t, db, ref, 2, cms.Associations{Terms: []string{"go"}}, true)
save(t, db, ref, 3, cms.Associations{}, true)
old, err := reader.Revision(ctx, ref, 2)
if err != nil || len(old.Terms) != 1 || old.Terms[0] != "go" {
t.Fatal("clearing optional classification rewrote history", err)
}
page, err = reader.Members(ctx, "go", nil, 10)
if err != nil || len(page.Items) != 0 {
t.Fatalf("cleared classification still published: %+v %v", page, err)
}
}
func TestTermRenameRetirementAndScope(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "writing", ID: "essay"}
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
term, e := r.Term(ctx, "go")
if e != nil {
t.Fatal(e)
}
term.Slug = "golang"
term.Name = "Go language"
term.Revision = 2
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
alias, e := r.TermBySlug(ctx, "topics", "go")
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
t.Fatal("alias", e)
}
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
duplicate := term
duplicate.Revision = 1
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
other, _ := New(db, "other")
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("cross scope term", e)
}
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
})
p, e := other.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("scope leak", e)
}
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("membership lost on rename", e)
}
term.Active = false
term.Revision = 3
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("retired term discovery", e)
}
old, e := r.Revision(ctx, ref, 1)
if e != nil || len(old.Terms) != 1 {
t.Fatal("retirement rewrote history", e)
}
save(t, db, ref, 2, old, true)
}
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
for i := 0; i < 35; i++ {
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
}
var after *cms.Reference
var ids []string
for {
p, e := r.Members(ctx, "go", after, 2)
if e != nil {
t.Fatal(e)
}
for _, v := range p.Items {
ids = append(ids, v.ID)
}
if p.Next == nil {
break
}
after = p.Next
}
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
t.Fatal(ids)
}
tx, e := db.BeginTx(ctx, nil)
if e != nil {
t.Fatal(e)
}
ref := cms.Reference{Kind: "project", ID: "rollback"}
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
t.Fatal(e)
}
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
t.Fatal(e)
}
if e = tx.Rollback(); e != nil {
t.Fatal(e)
}
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("partial transaction", e)
}
}
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"gamertan.com/web/cms"
)
func notFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return cms.ErrNotFound
}
return err
}
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
var v cms.Taxonomy
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Taxonomy{}
for rows.Next() {
var v cms.Taxonomy
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
if len(values) > 100 {
return nil, cms.ErrInvalid
}
return values, rows.Err()
}
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
var result sql.Result
var err error
if expected == 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
return err
}
if count >= 100 {
return cms.ErrInvalid
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
}
return oneRow(result, err)
}
func oneRow(result sql.Result, err error) error {
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return cms.ErrConflict
}
return nil
}
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
var v cms.Term
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
// Terms pages by immutable ID, including retired values for editors. The caller
// filters public availability using both taxonomy and term Active fields.
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
return nil, cms.ErrInvalid
}
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Term{}
for rows.Next() {
var v cms.Term
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
return values, rows.Err()
}
// TermBySlug resolves old term slugs to the current record. The caller redirects
// to v.Slug after checking visibility; no private/retired term is published here.
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
var id string
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
if err != nil {
return cms.Term{}, notFound(err)
}
return r.Term(ctx, id)
}
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
r, _ := New(tx, scope)
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
if err != nil {
return err
}
if !tax.Active && v.Active {
return cms.ErrInvalid
}
var old cms.Term
if expected > 0 {
old, err = r.Term(ctx, v.ID)
if err != nil {
return err
}
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
return cms.ErrConflict
}
}
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
if err == nil && occupied.ID != v.ID {
return cms.ErrConflict
}
if err != nil && !errors.Is(err, cms.ErrNotFound) {
return err
}
var result sql.Result
if expected == 0 {
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
}
if err = oneRow(result, err); err != nil {
return err
}
if expected > 0 && old.Slug != v.Slug {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
}
return err
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: MPL-2.0
// Package web is the documentation root for Gamertan Web Foundations.
//
// Web Foundations is a collection of small, composable Go packages for the
// security-sensitive edges of a web application: request identity, structured
// request evidence, browser security, authentication, passkeys, permissions,
// organizations, SQLite persistence, abuse controls, and private analytics.
//
// It is a toolkit rather than an application framework. Applications keep
// their router, handlers, HTML, authorization decisions, deployment, and
// operational policy. Packages use net/http and can be adopted independently.
// No Redis, message broker, hosted identity provider, telemetry service, or
// JavaScript framework is required.
//
// # Choose a first boundary
//
// Start with the smallest package that owns the boundary you need:
//
// - [requestmeta] resolves request IDs, client addresses, and trusted-proxy
// metadata once for downstream security and logging.
// - [requestlog] records bounded, versioned request observations with
// sensitive fields disabled by default.
// - [websec] supplies HTTP headers, same-origin checks, CSRF protection,
// redirects, body limits, and rate limits.
// - [auth], [authhttp], [authwebauthn], and [authsqlite] provide
// storage-neutral identity, secure browser sessions, passkeys, and an
// optional no-CGO SQLite adapter.
// - [organizations] and [access] model organizations, teams, invitations,
// scoped roles, and audited temporary access.
// - [cms] and [cmssqlite] add revision-aware taxonomies and editorial links
// alongside application-owned content and coded templates.
// - [abuse] applies application-classified request-abuse decisions.
// - [analytics] creates bounded, disposable projections from requestlog
// records without becoming a telemetry service.
//
// # Compose with net/http
//
// Middleware is wrapped from the application outward. A request metadata
// resolver should be outermost so packages inside it agree about request
// identity. The package example shows a complete, executable composition.
// A copyable server with graceful shutdown and optional private JSONL logging
// is available in the repository's starters/basic directory.
//
// # Security model
//
// Untrusted values are bounded before storage or aggregation. Forwarding
// headers affect identity only through explicitly trusted proxies. Sensitive
// request fields require field-by-field opt-in. Security-relevant
// configuration and persistence failures fail closed rather than silently
// weakening policy.
//
// This root package intentionally exports no runtime API. Applications import
// only the subpackages they use.
//
// [abuse]: https://pkg.go.dev/gamertan.com/web/abuse
// [access]: https://pkg.go.dev/gamertan.com/web/access
// [analytics]: https://pkg.go.dev/gamertan.com/web/analytics
// [auth]: https://pkg.go.dev/gamertan.com/web/auth
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
// [cms]: https://pkg.go.dev/gamertan.com/web/cms
// [cmssqlite]: https://pkg.go.dev/gamertan.com/web/cmssqlite
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
// [websec]: https://pkg.go.dev/gamertan.com/web/websec
package web
+23
View File
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
operator exclusions, synchronization, and publishing workflow remain
application-owned. Useful pressure from that migration may improve a general
interface, but it may not smuggle EQL-specific policy into this module.
## Optional personal-profile editing
`auth.OwnProfileRepository` supports a narrow self-service boundary independently
of instance-directory authorization. Load the profile using the current session
digest; derive the target from that result. Normalize one username or display
name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into
`auth.ProfileEdit`, which carries trusted identity and credential-check state.
Require CSRF/origin validation for browser writes and rate-limit credential work.
For username changes, verify the current password (supply its hash as
`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval;
enforce any additional authentication policy your application requires. Include
the session, user, value and expected profile revision in the passkey binding.
The SQLite transaction rechecks session/account/revision and any verified hash,
updates one field, revokes other sessions for username edits, and appends audit.
Do not log the command or include secret material in its audit.
Schema 11 adds `profile_revision` without changing stable identity keys. Run an
explicit migration before starting an adopter with automatic migration disabled.
Keep the pre-migration backup; adjacent older binaries are not approved writers
for the migrated schema. Email-change enrollment/confirmation is not implemented
by this interface and must not be simulated with an unverified direct update.
+31 -7
View File
@@ -2,22 +2,46 @@
# Architecture
The dependency direction is intentionally one-way:
The package dependency direction is intentionally one-way:
```text
net/http application
-> requestmeta
-> requestlog / websec / abuse / authhttp
-> auth and analytics interfaces
-> optional authsqlite and JSONL adapters
analytics ──> requestlog ──> requestmeta
abuse ─────────────────────> requestmeta
authhttp ──> websec ───────> requestmeta
authhttp ──> auth <───────── authsqlite
│ ▲ ▲
└──> authwebauthn ───────────┘
organizations <───────────── authsqlite
access <──────────────────── authsqlite
```
An ordinary `net/http` application composes whichever branches it needs.
Optional transactional email adds `authmail` → `auth`/`mail`, with `authsqlite`
implementing its storage protocol and using `mailsqlite` for atomic encrypted
outbox writes. `mail` is standard-library-only; no broker or hosted mail service
is required. Applications own recipient authority, trusted link origins, forms,
SMTP configuration and worker scheduling. See the [protocol guide](../authmail/README.md).
Packages never own application routes, templates, authorization policy, cache
policy, or deployment. Middleware communicates through typed request context.
Storage and reporting surfaces are interfaces so an application can retain its
existing database and user interface while replacing one implementation at a
time.
Authentication establishes one user identity and session. Organizations own
projects, environments, and services; teams group organization members; scoped
access resolves roles against that hierarchy. Existing `auth` roles remain a
platform-level compatibility surface and do not implicitly grant access to an
organization's data. Emergency access is a separate, expiring, audited grant.
`authwebauthn` owns relying-party policy and ceremony orchestration but not
application routes. It stores only opaque token digests, bounded verifier
session state, credential public records, and audit metadata through an
interface implemented by `authsqlite`. The existing `auth` service issues the
ordinary opaque session only after the passkey verifier succeeds.
The package model is developed from explicit threat and data contracts, not by
moving an existing application's internals into a shared directory. See
[ADOPTION.md](ADOPTION.md).
[ADOPTION.md](ADOPTION.md), [GETTING_STARTED.md](GETTING_STARTED.md), and the
[module-boundary policy](MODULES.md).
+74
View File
@@ -0,0 +1,74 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Classification without a page builder
`cms` defines small editorial values. `cmssqlite` stores them in caller-owned
SQLite transactions. Neither package owns your content, router, templates,
permissions or billing model. Use them alongside typed Go records and coded
templates, not as a database-defined application builder.
## Two distinct relationships
- A **taxonomy** names a flat vocabulary, such as Categories or Topics. A **term**
has a stable ID, editable name/description and an address. Renaming its address
preserves aliases; retiring it hides discovery without rewriting old revisions.
- An **explicit link** joins two `{kind, id}` references. `Related` reads that
single edge in either direction. Sharing a term alone does not assert a link.
References contain no titles, URLs, application data or authorization. Resolve
each public result through the owning repository using its current published
revision and availability. Never render the latest draft merely because an older
revision is published. Products may have additional availability/approval rules;
editorial links do not bypass them or change prices, entitlements or purchases.
## Transactions and publication
Call `CreateSchema(ctx, tx)` during an explicit application migration. It creates
the `gwf_cms_*` tables, independently of the authentication adapter's schema.
There is no automatic migration, connection, worker or request middleware.
Enable foreign keys on every connection and use the application's existing
SQLite write discipline. Keep backups and schema compatibility in that owner.
Every reader/writer takes a validated namespace. A namespace separates data; it
does not authorize the caller. Check permissions before reads and inside the
application's mutation boundary where concurrent revocation matters.
Within the same transaction as your content revision and audit:
1. `PutRevision` stores the exact revision's immutable term/link selections.
2. For publication, `SetPublished` points at that revision. Zero unpublishes.
3. Commit content, associations, publication and audit together.
Saving a draft does not advance publication. Restore by copying the selected
historical association document into a new revision, then publish separately.
Terms must exist in the namespace; retired terms remain valid historical values.
Applications decide which retired selections may be retained in new edits.
External targets can be indexed with an empty published snapshot, but their
owning module still determines whether a public link is available.
Taxonomy/term writes use expected revisions (zero for creation). Keep immutable
IDs across name changes. Taxonomy addresses are fixed after creation; term
addresses retain redirect history. A collision or stale revision returns
`cms.ErrConflict`, not a successful overwrite. Transactions must be rolled back
after any mutation error, including a later application audit failure.
## Bounds and discovery
- Each snapshot accepts at most 24 distinct terms and 16 distinct links, without
self-links. Validation rejects invalid IDs, control characters and duplicate
selections. Text fields have explicit byte bounds; names are not HTML.
- A namespace has at most 100 taxonomies. `Terms` pages by stable term ID, at
most 200 results per request. Applications should choose their own overall
editor limits and search UI rather than loading an unbounded catalog.
- `Members` and `Related` return at most 100 published references per page,
ordered by kind/ID. Pass `Next` for the following page. Publication filtering
happens before pagination; never use a mutable title as a cursor.
- Owning-module visibility can filter further. Continue fetching bounded index
pages to fill a visible page and construct a cursor from the last visible
item; do not expose private titles or identifiers through error messages.
The package tests use real SQLite transactions, including WAL, race execution,
scope isolation, delayed publication, reverse discovery, aliases, retirement,
pagination and rollback. Consumer tests still need to prove actual HTTP/API
permissions, public visibility, escaping, editor usability and application data
preservation. These packages do not claim to provide an entire CMS.
+40 -1
View File
@@ -2,17 +2,56 @@
# Dependency boundary
Most packages use only the Go standard library. Two direct modules are pinned:
Most packages use only the Go standard library. The principal implementation
dependencies are pinned:
- `golang.org/x/crypto` supplies the reviewed Argon2id implementation used by
`auth` (BSD-3-Clause upstream licence).
- `modernc.org/sqlite` supplies the no-CGO SQLite adapter in `authsqlite`
(BSD-3-Clause upstream licence).
- `github.com/go-webauthn/webauthn` `v0.17.1` supplies the audited source for
WebAuthn Level 3 parsing and cryptographic verification in `authwebauthn`
(BSD-3-Clause upstream licence; source commit
`de0a809e3027957ca15b72b252540317f9ba581b`). Its imported transitive modules
are pinned directly in `go.mod` because the verifier is compiled internally.
The exact, unchanged `go-webauthn` module source is retained at
`third_party/go-webauthn`. The non-test Go files from the packages used by
`authwebauthn` are copied into `internal/webauthnvendored`; only their
self-import prefix is mechanically rewritten. A derivation gate recreates that
internal tree from the audited source and requires a byte-for-byte match before
tests or builds. The complete upstream file manifest, upstream module checksum,
source commit, licence, and downloaded module ZIP SHA-256 are checked in.
The derivative is exercised by ordinary and race-enabled tests, but is excluded
from repository formatting so that gate cannot rewrite the audited upstream
source. The repository still runs `go vet` over the complete graph and permits
only the exact upstream warning for its unexported COSE structure sentinel;
every other vet diagnostic fails verification.
This arrangement is deliberate. A `replace` directive in a library module is
ignored by downstream consumers, so it cannot guarantee which verifier source
an application compiles. The public module has no local replacement and no
direct `github.com/go-webauthn/webauthn` module requirement; applications
compile the checked internal derivative instead. Its transitive modules remain
pinned by `go.mod`, `go.sum`, and SumDB. Release builders populate an isolated
verified module cache before offline compilation.
A conventional repository-wide `go mod vendor` would also copy the SQLite and
full transitive graph, currently roughly 143 MiB and more than 2,300 files.
That unrelated expansion is deliberately avoided: only the security-sensitive
WebAuthn verifier named by the policy is source-vendored here.
Applications that do not import `auth` or `authsqlite` do not link those
implementations into their binaries. Optional GeoIP enrichment is an interface
only; the base toolkit performs no lookup and adds no GeoIP dependency.
All packages currently share one Go module, so these requirements remain
visible in the module graph even when an application imports only
`requestmeta`. Go still avoids compiling or linking unused packages. A future
nested module may isolate a heavyweight adapter such as `authsqlite` when its
independent dependency and release lifecycle justify the additional tags,
vanity metadata, and CI. See [MODULES.md](MODULES.md).
`go.sum`, `go mod verify`, checksum-database verification, vulnerability
scanning, and the public snapshot allowlist are release gates. Binary
distributors remain responsible for preserving all applicable upstream notices.
+154
View File
@@ -0,0 +1,154 @@
<!-- 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
- Typed content needs shared categories and relationships without becoming a
page builder. The `cms`/`cmssqlite` boundary separates application-owned bodies
and products from immutable editorial associations. Keeping associations and
publication in the content transaction prevents a draft save from changing
public reverse links. Stable references avoid rewriting purchases on a project
rename. Consumer HTTP tests caught compiled article snapshots shadowing CMS
revisions and catalog pickers showing newer draft titles; those are application
routing/visibility responsibilities, not extra policy in this package.
- Personal identity editing is not instance administration. `OwnProfileRepository`
derives self-access from the active session; `ProfileEdit` is a trusted internal
command, never a browser request model. SQLite schema 11 adds a monotonic
revision because timestamps alone cannot distinguish two edits in one second.
Session/account/revision checks, mutation and audit share one write transaction.
Username edits invalidate other sessions without changing immutable IDs,
memberships, credentials, orders or provider billing identities. A password
proof binds the verified hash into that transaction; passkey proofs must bind
the exact user/session/field/value/revision before calling it. The application
chooses account-specific reauthentication and owns its credential-work limits.
Email requires a separate verified change protocol, not another accepted field.
- Instance operators need all-user/all-organization directories, not a staff
roster or implicit membership in every business. Optional bounded readers now
expose identity/profile records without credentials, independent of membership.
The application must authorize each call through an explicit instance scope;
these readers intentionally contain no Gamertan-specific roles or UI policy.
Stable-ID cursors and literal searches are covered against pagination gaps,
renamed profiles, inactive/personal records and wildcard/query injection.
- Customer profile and membership editing requires current ownership for every
write, not just changes involving another owner. The existing generic methods
intentionally permit application-authorized delegated administrators, so an
application preflight alone would leave a demotion race. Explicit owner-managed
methods now share their transactional cores while rechecking current direct
ownership before any write. Tests cover stale authority and optimistic state,
last-owner protection, concurrent winners, and audit-failure rollback. No extra
passkey ceremony or database migration is needed for this invariant.
- A customer may need both purchasing and billing access. Replacing one role at
a time would create partial permission states and misleading audit history.
The role-set extension commits all direct roles together with optimistic
binding IDs and current owner authority. Multiple-role invitations carry the
same combination atomically, with stored owner-managed policy rechecked when
accepted. SQLite tests cover concurrent winners, write rollback, demoted or
removed grantors, and attempted implicit reactivation of suspended members.
This adds schema 10; application vocabulary, allowed roles, invitation delivery,
ordinary-customer authentication, and UI/API commands remain application-owned.
- The public export allowlist omitted the owned-organization files introduced
in preview 22. Including them and building the exported tree tests the actual
distribution boundary rather than only comparing its path list with itself.
- Shared business purchasing exposed the difference between an initial member
and an initial RBAC owner. The historical organization creation method commits
membership but no access binding. The new `CreateOwnedOrganization` extension
grants the application-configured role and writes both audits atomically for
an existing active, fully registered user. It rejects missing roles and
unsupported adapters instead of leaving an ownerless organization behind.
SQLite tests inject failure at every write stage, including the second audit,
and race duplicate creates. Customer/merchant vocabulary remains application
policy; there is no new database schema or commerce dependency in Foundations.
- The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier; the immutable user ID, not the editable
username, owns account relationships. Until a mail package exists, it 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.
- A portless-only WebAuthn origin rule made an unprivileged local HTTPS
exercise impossible even though WebAuthn origins include ports. The passkey
service now permits an explicit development port only when applications opt
in and the RP ID is `localhost` or reserved `.test`; production origins keep
the original portless default.
- Gamertan's staff-access page exposed a dangerous composition gap between
individual grant/revoke calls. Foundations now owns one optimistic,
transactional direct-role replacement that preserves the final active
owner and appends its audit before commit. The application still owns route
authorization, role presentation, CSRF, and the exact fresh-passkey
operation binding.
- Extending that page to membership suspension, reactivation, and removal
exposed the same time-of-check gap in the older lifecycle methods. The new
optimistic extension serializes on the active administrator membership,
rechecks the exact state bound into the passkey assertion, applies team and
direct-binding consequences, and writes the audit in one transaction.
- Human-assisted recovery cannot safely be expressed as a root command behind
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
application performs the human review and fresh operation-bound passkey
ceremony, while the SQLite transaction rechecks an active direct owner,
invalidates every old account authenticator, stores only the grant digest,
and writes identity plus organization audits. Grant completion installs the
replacement password, passkey, and recovery-code set atomically and never
issues a session.
- Gamertan's distinction between Site Admin and Owner exposed a second
composition boundary: permission to manage ordinary staff must not imply
permission to create, demote, suspend, or remove an Owner. Preview 19 moves
that invariant into the same SQLite transactions as direct-role and
membership changes, while leaving the application's role vocabulary and UI
policy application-owned.
- Gamertan's invitation work found the same authority boundary before a route
was exposed: Site Admin must be able to invite ordinary staff without being
able to grant or cancel Owner access. Preview 20 passes the configured owner
role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation.
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
verification but was rejected by a redundant algorithm check because the
application's direct response serializer omitted the optional browser
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
enforcement but derives it from the verified COSE key embedded in
authenticator data. This makes the server independent of serializer-specific
convenience fields without weakening origin, challenge, user-verification,
or algorithm validation.
+130
View File
@@ -0,0 +1,130 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Getting started
Gamertan Web Foundations is adopted one boundary at a time. Start with the
smallest package that solves a problem the application actually has; do not
install an imagined framework lifecycle around it.
## Choose a first slice
| Application need | Begin with | What remains application-owned |
| --- | --- | --- |
| Request IDs and trustworthy client addresses | `requestmeta` | Proxy configuration and operational logs |
| Bounded structured request evidence | `requestmeta`, `requestlog` | Route names, sensitive-field policy, rotation, retention, and access |
| Browser and HTTP safety primitives | `requestmeta`, `websec` | Exact CSP, route authorization, and response policy |
| Persistent request-abuse decisions | `requestmeta`, `abuse` | Route classification, storage, appeals, and operator policy |
| Users, credentials, permissions, and sessions | `auth` | Roles, permissions, login UX, and account policy |
| Secure browser cookies around `auth` | `authhttp` | Login routes, redirects, pages, and authorization decisions |
| 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.27
go mod verify
```
## Preserve middleware order
Packages that consume request metadata must run inside the resolver. Build the
handler from the application outward; the final resolver assignment becomes
the first middleware to receive a request:
```go
var handler http.Handler = router
handler = requestlog.Middleware(sink, logPolicy)(handler)
handler = websec.Headers(headerPolicy)(handler)
handler = resolver.Middleware(handler)
```
The complete, copyable composition is in [`starters/basic`](../starters/basic).
It binds to loopback, shuts down gracefully, and keeps request logging optional.
`requestlog.OpenJSONL` creates a private mode-`0600` file. If a separate,
unprivileged collector such as Observatory is the only approved reader, prepare
a trusted setgid directory whose group is that collector, then opt into
`requestlog.OpenJSONLWithOptions(path, requestlog.JSONLOptions{FileMode: 0o640})`.
The application still owns rotation, retention, disk monitoring, and sink-error
health. Never use a world-readable log or add the collector to the application
account's broader groups merely to make collection convenient.
Configure trusted proxy networks narrowly. A forwarding header is not evidence
by itself; it becomes usable only when the immediate peer and skipped proxy
hops satisfy the resolver's trust policy. Metadata, authentication, or storage
failures that affect security decisions should stop the request rather than
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
not write or print the credential because file ownership, operator identity,
and delivery are application policy.
After authentication, inspect `principal.User.PasswordChangeRequired`. Until it
is false, permit only password change and logout. `auth.ChangePassword` verifies
the current credential, rejects reuse, writes the new Argon2id hash, clears the
requirement, and revokes every existing session atomically through the storage
adapter. Clear the browser cookie and require a fresh login after success. Do
not treat a redirect alone as enforcement; apply the restriction before every
protected handler.
For operator-led recovery, expose `auth.ResetPassword` only through a local
administrative command—not a public HTTP endpoint. The operation installs an
application-generated one-time credential, sets `PasswordChangeRequired`,
revokes every existing session, and appends a secret-free audit event in the
same repository transaction. Deliver that credential through an exclusive
root-owned mode-`0600` file, delete it after successful rotation, and never put
it in command arguments, stdout, logs, manifests, or deployment state.
## Add HTML without merging responsibilities
Handlers should convert request and service state into typed display data.
They may then render those values with any HTML system. Gamertan's preferred
companion is [Sandwich Hime](SANDWICH_HIME.md), whose generated components keep
templates typed while leaving this middleware stack and the `net/http`
application in control.
## Verify the application boundary
After adopting a package:
```bash
go mod verify
go test ./...
go test -race ./...
go vet ./...
go build ./...
```
Test the composed handler with `httptest`, not only the package in isolation.
Include a normal request, malformed or spoofed metadata, a downstream failure,
and the application's intended response headers. Existing applications should
follow the differential and rollback sequence in [ADOPTION.md](ADOPTION.md).
Deeper tutorials for accounts, analytics, and persistent abuse policy will be
written after multiple application migrations have validated those seams. The
preview documentation describes demonstrated contracts rather than prescribing
an unfinished application framework.
See [Organizations and scoped access](ORGANIZATIONS.md) before storing tenant
data. In particular, do not interpret a platform role as permission to inspect
an organization's records.
+78
View File
@@ -0,0 +1,78 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Packages, modules, and repositories
These boundaries solve different problems:
- a **package** owns one Go responsibility and import path;
- a **module** owns dependency selection and semantic versions; and
- a **repository** owns contribution, security, and release operations.
The first preview uses one repository and one module, `gamertan.com/web`, with
several independently importable packages. An application may write:
```go
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.25
```
Only imported packages are compiled and linked. The packages nevertheless
share the module's version and dependency graph.
## Why not one repository per package?
Separate repositories would multiply release credentials, security updates,
vanity-import records, tags, CI, issue tracking, and coordinated API changes.
A focused pull request can already change and test one package directory. A
repository boundary is reserved for software with an independently operated
lifecycle, such as a future standalone `authd` service.
## When a nested module is justified
A package may become a nested module inside this repository when all of these
are true:
1. it introduces materially heavier or different dependencies;
2. consumers can usefully version it independently;
3. its API boundary has survived real application adoption; and
4. separate tags, release ordering, vanity metadata, and CI are less costly
than keeping it in the root module.
`authsqlite` is the clearest current candidate because it carries the optional
SQLite implementation and its transitive module graph. A future split could
retain the import path `gamertan.com/web/authsqlite` while giving that directory
its own `go.mod` and tags such as `authsqlite/v0.1.0-preview.1`.
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:
- `auth` owns opaque token creation, digest-backed session lookup, revocation,
and the storage interface;
- `authhttp` binds those sessions to secure browser cookies and request
context; and
- `authsqlite` persists the storage contract.
A separate `session` package would be appropriate only for a genuinely
identity-neutral need, such as anonymous application sessions with no user,
role, or credential semantics. It should not duplicate `auth` under a more
general name.
This policy may evolve before a stable release. Any split must include a
migration guide and preserve already published versions at their original
module coordinates.
+201
View File
@@ -0,0 +1,201 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Organizations and scoped access
One `auth.User` may belong to many organizations without creating another
credential or browser session. Organizations own projects; projects own
environments; environments own application services. Teams are optional groups
of active organization members.
`organizations.Service` creates those resources and issues digest-backed,
expiring, single-use invitations. An invitation may carry up to sixteen direct
roles and sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership,
roles, teams, consumption marker, and audit event in one transaction. The
recipient and issuing member must remain active, fully registered users of an
active organization; a suspended recipient cannot use an invitation as implicit
reactivation. Existing members use the membership editor, not another invitation,
to change roles or teams. Duplicate or concurrent acceptance consumes the token
only once. Removal revokes older pending invitations for that recipient in the
same transaction; a new, intentional invitation is needed to rejoin later.
When `OwnerRole` is configured, invitations granting that role require a current
direct owner at creation and acceptance, and an owner for revocation. Set
`OwnerManagedInvitations: true` to apply that rule to every invitation, including
ordinary member invitations. Stored `RequiredOwnerRole` preserves the boundary
even when a link reaches another application service with different options.
A broad access-management permission can still administer ordinary invitations
when owner-managed policy is disabled, but cannot create or cancel owner access.
Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery.
Use `InviteWithAccess.DirectRoles` for combinations and `RequestID` for the
creation audit correlation. `DirectRole` remains the legacy single-role form;
supplying both is rejected, not merged. The service copies and sorts role arrays
and rejects duplicates or unknown/unseeded roles before persistence. Repository
adapters implement `RoleInvitationRepository` to store and enforce role-set and
owner requirements atomically. An unsupported adapter returns
`ErrRoleInvitationUnsupported`; it must not issue a partly effective invitation.
The application restricts which roles may be offered and authenticates the actor;
never accept the owner-role policy or actor identity from submitted fields.
## Creating an organization with an owner
For instance-wide administrative directories, the optional
`auth.UserDirectoryRepository` and `organizations.DirectoryRepository` readers
on `authsqlite.Store` list all identities/organizations, not just memberships.
**Authorize an explicit instance-read capability before every call.** These are
not customer self-service or public directory APIs; they deliberately include
incomplete/inactive accounts and personal/archived organizations without exposing
credentials, recovery material or invitations. Merchant classification remains
application policy. Reading never creates a membership or grants a role.
Both queries accept literal `Search` (up to 128 bytes), exclusive `AfterID`, and
`Limit` (default 50, maximum 200). An empty `NextID` ends the result. Preserve the
search when following a cursor; reset it when changing the search. IDs give stable
ordering despite renamed profiles, but pages are current views rather than a
multi-request snapshot. New records sorting before a cursor appear on a fresh
listing. SQLite search folds ASCII case; non-ASCII display-name text matches with
its original case. Wildcards and SQL fragments are always literal search text.
These optional readers do not change the required authentication/organization
repository contracts or schema 10.
For an existing authenticated user creating a business, use
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
service. Seed that role first. This commits the organization, active membership,
direct organization-wide owner binding, and both creation/access audit events in
one transaction. `CreateOrganization.RequestID` correlates those audit events.
The owner must be an active user whose registration has completed.
The application authorizes creation and chooses the role; do not accept an owner
role name from a browser or API payload. A customer-owner role can intentionally
have different permissions from an installation's merchant-owner role. Creating
a customer organization grants no authority in any other organization.
```go
customers, err := organizations.New(store, organizations.Options{
OwnerRole: "customer.owner", // Application-defined, already seeded.
OwnerManagedInvitations: true,
})
if err != nil {
return err
}
business, err := customers.CreateOwnedOrganization(ctx, organizations.CreateOrganization{
Slug: "example-business", Name: "Example Business", OwnerUserID: principal.User.ID,
RequestID: requestID,
})
```
Repositories implement `OwnedOrganizationRepository` to support this operation.
There is no create-then-grant fallback: unsupported adapters return
`ErrOwnedCreationUnsupported`. The older `CreateOrganization` and
`CreatePersonalOrganization` retain their membership-only behavior; configuring
`OwnerRole` does not silently change them. The separate `account` package still
owns atomic public signup, including personal organization and credentials.
## Membership and access lifecycle
For customer-owned businesses where only owners manage profiles and members,
use `UpdateOwnedOrganization`, `ChangeOwnedMembershipStatus`, and
`RemoveOwnedMembershipIfCurrent`. They recheck the service's configured direct
owner after acquiring the write lock, including when the target is a non-owner.
The actor must remain active and fully registered, their membership must remain
active, and the organization must be active and non-personal. Profile changes
only update name and slug; archiving and personal-account lifecycle are separate.
Custom adapters must implement `OwnerManagedRepository`, with no fallback to an
application preflight followed by an unguarded write. These additions retain
schema 10 and do not change the existing delegated-administration methods below.
Organizations and teams use optimistic revisions and reversible
`active`/`archived` states. Archived objects keep their history but contribute
no effective authority. Memberships may be suspended, reactivated, or removed;
team membership can be removed independently. Configure `OwnerRole` when
constructing the service before exposing membership-removal operations. The
SQLite adapter then refuses to suspend or remove the final active direct owner.
Fresh-authentication administration pages should use
`ChangeMembershipStatus` and `RemoveMembershipIfCurrent`, passing the exact
displayed state as `ExpectedStatus`. The SQLite adapter acquires its write lock
before checking that state, verifies the actor is still an active member of an
active organization, and commits the lifecycle effects and audit together.
Suspension removes team memberships; reactivation does not infer or restore
them. Removal also revokes current direct bindings. A repository without the
optimistic extension fails closed instead of falling back to a stale mutation.
For a reviewed access-administration page, use `organizations.Members` to list
bounded active and suspended memberships, and
`access.OrganizationUserBindings` to list only current direct,
organization-wide user roles. The latter intentionally excludes team grants
and project, environment, or service bindings. Replace a member's direct role
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
rejects stale state, writes the new binding and audit event atomically, and
will not demote the final active direct owner. The application must still
authorize the administrator and bind any required fresh passkey assertion to
the organization, target user, target role, and expected IDs.
For combinations such as Buyer plus Billing Manager, use
`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array
(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation
requires a current direct owner inside the write transaction for every change;
the older single-role API retains its delegated non-owner administration policy.
The replacement is all-or-nothing, leaves narrower grants untouched, and records
one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry
the old request silently. `RoleSetRepository` is required; separate grant/revoke
calls are not a fallback. A basic-member role with no permissions can represent
membership without purchasing or billing access.
Role names and capabilities remain application policy. In particular, customer
roles must not be replaceable with merchant roles merely because both policies
use the same database. Routine customer changes do not inherently require a
passkey ceremony; the application decides when an action needs fresh proof.
## Schema 10 compatibility
Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations.
The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and
consumption state. It does not guess which application role historically meant
owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner
invitations; that service policy supplies their acceptance-time owner check.
New invitations carry the persisted requirement themselves.
Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus
`RequireCurrentSchema` at application startup and an explicit operator migration
command. Retain a verified backup before migrating. Schema-9 binaries reject
schema 10 when using the startup check and are not approved writers after the
upgrade; a binary rollback must not overwrite newer accepted data.
`access.Service` evaluates a permission against a complete resource scope:
```go
decision, err := accessService.Authorize(ctx, principal.User.ID, access.Scope{
OrganizationID: organizationID,
ProjectID: projectID,
EnvironmentID: environmentID,
ServiceID: serviceID,
}, "telemetry.read")
```
A binding at organization scope covers its descendants. A narrower binding
covers only its matching branch. The repository resolves team membership; a
handler must never accept caller-supplied team identifiers as authority.
Platform roles in `auth.Principal` remain useful for installation health,
account administration, and other explicitly global operations. They do not
grant organization-data access. If an operator must inspect tenant data during
an incident, use a reasoned break-glass grant. It expires within one hour and
creates an append-only audit event in the same transaction.
An application that offers owner-assisted account recovery must not infer that
authority from a broad administration page. Use the dedicated
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
assertion. The SQLite adapter requires a current active direct owner binding
and active target membership in the same transaction that invalidates the old
credentials and records the organization-visible recovery audit. Team,
break-glass, platform, and merely descriptive roles do not satisfy this owner
check.
The SQLite adapter namespaces all tables, enforces active organization and team
membership plus resource ancestry before accepting or evaluating a binding,
and keeps invitations and sessions as digests. Applications remain responsible
for database backup, filesystem ownership, retention, and presenting audit
history to organization owners.
+110
View File
@@ -0,0 +1,110 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Passkey integration
`authwebauthn` is a passkey ceremony service, not a login page or account
policy. An application supplies its exact relying-party identity, routes,
authorization decisions, session cookie, HTML, and local recovery command.
## Fixed security policy
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
- Keep production origins portless. For local development only,
`AllowDevelopmentPort` permits one explicit non-default port when the RP ID
is exactly `localhost` or beneath the reserved `.test` top-level domain. The
configured origin, browser `Origin`, and WebAuthn verifier origin must still
match exactly.
- Reject cross-origin ceremonies.
- Require discoverable credentials and user verification.
- Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and
security evidence.
- Enforce that policy from the verified COSE public key embedded in
authenticator data. Do not rely on the optional browser
`publicKeyAlgorithm` convenience member: direct standards-compliant response
serializers may omit it even when the attested credential is ES256.
- Store random challenges and verifier session data only behind opaque,
single-use ceremony tokens.
- Treat clone warnings as audit signals rather than automatic lockout for
synchronized passkeys.
The service uses a random WebAuthn challenge for every ceremony. A sensitive
application operation is bound separately by storing the SHA-256 digest of its
canonical payload with that ceremony. Never substitute an operation hash,
timestamp, UUID, or counter for the random challenge.
## Application flow
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; 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
payload and `FinishApproval` with those exact same bytes. Any drift fails.
For an existing password-backed account, call `BeginPasswordMigration` only
from an authenticated account session and finish with
`FinishPasswordMigration`. The registration ceremony is bound to that user.
Successful completion stores the passkey, removes the password credential,
clears the password-change flag, revokes every session and pending ceremony,
and appends the audit event atomically. The application must clear the current
session cookie and return the user to passkey login after success.
`authhttp.WritePasskeyBegin` and `authhttp.ReadPasskeyFinish` provide bounded
JSON framing only. They do not register routes, authorize requests, serve
JavaScript, or set sessions automatically.
## Recovery and credential lifecycle
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
must never be reachable through an HTTP handler. It revokes all user sessions
and pending ceremonies, replaces prior enrollment tokens, appends a
secret-free audit event, and returns one 15-minute token. It does not delete
existing passkeys. After enrolling a replacement, the operator reviews
credential labels and removes lost keys with a fresh passkey-bound removal
ceremony. The final passkey cannot be removed remotely.
An account may separately expose self-service password-plus-recovery-code
recovery through `authrecovery`. `Begin` verifies the password, consumes one
printable code, revokes sessions, and returns a short-lived grant—not a normal
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
replacement passkey, replaces the entire recovery-code set, revokes any
sessions or ceremonies created during recovery, and returns the new plaintext
codes exactly once. It does not issue a session; return the user to normal
login after displaying and saving the new codes.
A failed storage commit leaves the restricted grant available for a fresh
ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
Applications must use generic failure responses and the same credential-attempt
rate limiting as login.
Owner-assisted recovery is a third, deliberately separate path. Configure
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
and bind that owner's fresh passkey assertion to the exact organization,
target user, request identifier, and bounded human-review reason before calling
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
owner and target membership, invalidates the target's password, passkeys,
recovery codes, sessions, and pending ceremonies, then stores only a digest of
the 15-minute grant with identity and organization-visible audits.
Deliver the returned grant exactly once in a URL fragment. A public recovery
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
keeping it out of request URLs, referrers, and access logs. Completion consumes
the grant atomically with one replacement password, passkey, recovery-code set,
and both audit trails. It issues no session. Losing the fragment after issuance
requires another reviewed owner or root-local recovery; old authenticators
must not become valid again as a fallback.
Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill.
+5
View File
@@ -9,3 +9,8 @@ the exact same exported tree as a read-only discovery mirror.
The exporter includes no branches, reflogs, private operational evidence,
credentials, databases, logs, or development-only files. Public Gitea issues
and pull requests are the contribution venue.
`scripts/test-public-snapshot.sh` checks the exact allowlist and builds all
exported packages. New implementation and regression-test files must be included
explicitly; a successful build in the development checkout does not prove that
the smaller exported distribution is complete.
+71
View File
@@ -0,0 +1,71 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# HTML with Sandwich Hime
Gamertan Web Foundations owns reusable web-application boundaries; it does not
own HTML or a template language. [Sandwich Hime](https://sandwichhime.com/) is
the preferred companion for Gamertan applications that want HTML-first,
ahead-of-time templates with typed Go composition.
The relationship is intentionally optional:
| Application responsibility | Owner |
| --- | --- |
| Request identity, logging, security primitives, sessions, and analytics | Web Foundations packages selected by the application |
| Routing, authorization decisions, status, headers, caching, and deployment | The application |
| Visible HTML and typed component composition | Authored `.sando` templates |
| Template parsing, contextual analysis, and Go generation | Hime-san during development or CI |
| Rendering generated components | The small `sando` runtime in production |
Web Foundations does not import Sandwich Hime. Sandwich Hime does not import
Web Foundations. An application chooses both and provides the seam between
them.
## Request flow
```text
request
-> requestmeta / selected middleware
-> application router and handler
-> typed view data
-> generated Sandwich Hime component
-> buffered sando.Render
-> application-owned HTTP response
```
Buffer the component before committing a successful response so a rendering
error can still become a clean application error:
```go
func renderHTML(response http.ResponseWriter, request *http.Request, status int, component sando.Component) {
var output bytes.Buffer
if err := sando.Render(request.Context(), &output, component); err != nil {
log.Printf("render page: %v", err)
response.Header().Set("Cache-Control", "no-store")
http.Error(response, "could not render page", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "text/html; charset=utf-8")
response.WriteHeader(status)
_, _ = response.Write(output.Bytes())
}
```
The handler—not the template—should interpret request metadata, principals,
permissions, analytics, or storage errors. It passes only the resulting typed
display data into the component. Templates should not acquire an implicit
request global or turn middleware context into an inheritance framework.
Handwritten `sando.Component` implementations and `Trust*` values are explicit
trusted-output capabilities. Keep them conspicuous and review them separately
from ordinary untrusted values.
## Continue with the official lessons
- [Build a component, page, and small site](https://sandwichhime.com/docs/tutorial/).
- [Follow a request through a larger Go application](https://sandwichhime.com/docs/tutorial/application/).
- [Review the Sandwich Hime security boundary](https://sandwichhime.com/docs/security/).
Those tutorials own the template syntax and compiler workflow. This repository
documents only the application seam so the two projects do not drift into a
single mandatory framework.
+65 -3
View File
@@ -10,7 +10,44 @@ selected storage adapters are trusted.
Controls include explicit proxy trust, bounded parsing, cryptographic request
and session identifiers, digest-only session storage, Argon2id passwords,
constant-time comparisons, same-origin and CSRF primitives, fail-closed storage
errors, and separate safe/sensitive analytics projections.
errors, separate safe/sensitive analytics projections, organization-scoped
bindings, single-use invitation digests, and short-lived audited break-glass
grants.
Passkey ceremonies require one exact HTTPS origin and relying-party ID,
discoverable credentials, user verification, and single-use random challenges.
The SQLite adapter consumes enrollment tokens and ceremonies atomically.
Operation approvals retain a separate digest of canonical application state;
the random WebAuthn challenge is never replaced by a predictable state hash.
Cross-origin ceremonies, unsupported credential algorithms, expired state, and
binding drift fail closed. Signature-counter clone warnings are surfaced for
audit but do not automatically lock out multi-device passkeys whose counters
legitimately remain zero.
An application may create an account with a cryptographically generated
temporary credential and `RequirePasswordChange`. Successful rotation compares
the current credential, replaces its Argon2id hash, clears the requirement, and
revokes every session in one repository transaction. The application must
restrict such a principal to password change and logout until rotation succeeds;
the library does not infer route policy. Temporary credentials must be written
to a private channel or mode-`0600` file and must never be printed into logs,
manifests, process arguments, or deployment state.
Administrative recovery is deliberately a separate capability. The storage
adapter atomically replaces the credential, restores the password-change
requirement, revokes all sessions, and appends a generic audit event. The core
library does not expose a recovery HTTP handler, deliver the credential, or
authorize the local operator. Applications must keep that command local,
generate the credential cryptographically, and write it only to a newly created
private file. A recovery must not reveal whether an account exists through a
public request surface.
Passkey-only recovery never creates a password or remote fallback. A local
administrator revokes sessions and active ceremonies and issues a short-lived
single-use enrollment token. Existing passkeys remain visible so the operator
can review and remove lost credentials after enrolling a replacement. The
library prevents remote removal of the final credential; applications should
require a freshly bound passkey assertion before every removal.
Unsafe methods without an exact Origin or trustworthy same-origin Fetch
Metadata fail the origin check. Authentication middleware fails closed when its
@@ -22,9 +59,34 @@ configured reverse proxy, authorize application routes automatically, encrypt a
compromised host, or decide how long an operator may lawfully retain personal
request evidence.
Optional account mail treats email links as bearer secrets, not sessions or MFA.
`authmail` binds random single-use digests to account identity, purpose, current
credential and expiry. Address changes additionally require current password,
session and both mailboxes. Reset requires an already verified current mailbox;
success creates no login and removes no enrolled factor. The SQLite adapter
atomically rechecks authority, revokes sessions/grants and queues notices with
the audit. Existing ownership and financial records are not reassigned.
SMTP authenticates only after verified TLS with no plaintext fallback. Encrypted
outbox payloads require an application-owned key, which may itself be wrapped in
SQLite only when its external wrapping key is kept separately. Compromise of
the sender, recipient mailbox or application host remains a threat. SMTP DATA
acceptance is not inbox delivery and crash retries are not exactly once. Forms,
CSRF/origin enforcement, fragment-to-POST handling, IP/concurrency limits and
non-enumerating responses remain explicit consumer responsibilities; see the
[integration boundaries](../authmail/README.md).
Applications must pass the authenticated user and requested resource hierarchy
to `access.Authorize`; possessing a platform-level `auth` role does not bypass
that decision. Team membership is resolved by the repository rather than
accepted from request input. Break-glass access lasts at most one hour and is
not a substitute for ordinary role policy.
Local storage adapters assume the parent directory and host account are trusted.
They reject a symlink at the configured final path and apply private file modes,
They reject a symlink at the configured final path and apply bounded file modes,
but they do not defend against a concurrent privileged actor replacing path
ancestors during an open. The synchronous JSONL adapter deliberately favors
durable, bounded evidence over maximum request throughput; the application owns
rotation, retention, disk monitoring, and health escalation.
rotation, retention, disk monitoring, and health escalation. Its default is
mode `0600`; the sole wider option is mode `0640` for a deployment-assigned
collector group. The toolkit does not select or change that group.
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MPL-2.0
package web_test
import (
"fmt"
"net/http"
"net/http/httptest"
"gamertan.com/web/requestlog"
"gamertan.com/web/requestmeta"
"gamertan.com/web/websec"
)
func Example() {
resolver, err := requestmeta.New(requestmeta.Config{})
if err != nil {
panic(err)
}
router := http.NewServeMux()
router.HandleFunc("GET /", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
var handler http.Handler = router
handler = requestlog.Middleware(nil, requestlog.Policy{
Route: func(*http.Request) string { return "home" },
})(handler)
handler = websec.Headers(func(*http.Request) websec.HeaderPolicy {
return websec.HeaderPolicy{
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
ReferrerPolicy: "no-referrer",
FrameOptions: "DENY",
}
})(handler)
handler = resolver.Middleware(handler)
request := httptest.NewRequest(http.MethodGet, "https://example.test/", nil)
request.RemoteAddr = "192.0.2.10:43120"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
fmt.Println(response.Code)
fmt.Println(response.Header().Get("X-Request-ID") != "")
fmt.Println(response.Header().Get("X-Content-Type-Options"))
// Output:
// 204
// true
// nosniff
}
+9 -1
View File
@@ -5,16 +5,24 @@ module gamertan.com/web
go 1.26
require (
github.com/fxamacker/cbor/v2 v2.9.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/go-webauthn/x v0.2.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/go-tpm v0.9.8
github.com/google/uuid v1.6.0
github.com/tinylib/msgp v1.6.4
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.56.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+26 -6
View File
@@ -1,5 +1,19 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -10,24 +24,30 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2025 github.com/go-webauthn/webauthn authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,41 @@
package metadata
const (
// ProductionMDSRoot is the root certificate for the MDS.
//
// See: https://secure.globalsign.com/cacert/root-r3.crt
ProductionMDSRoot = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpHWD9f"
// ProductionMDSURL is the Production MDS URL.
ProductionMDSURL = "https://mds.fidoalliance.org"
// ConformanceMDSRoot is the root certificate for the MDS Conformance Suite.
//
// See: https://mds3.fido.tools/pki/MDS3ROOT.crt
ConformanceMDSRoot = "MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtFIE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBGQUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRhdGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSLTKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8TEirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAWgBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMygX2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc="
// ExampleMDSRoot is the example root certificate for the MDS.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1-ps-20250521.html#sctn-examples
ExampleMDSRoot = "MIIGGTCCBAGgAwIBAgIUdT9qLX0sVMRe8l0sLmHd3mZovQ0wDQYJKoZIhvcNAQELBQAwgZsxHzAdBgNVBAMMFkVYQU1QTEUgTURTMyBURVNUIFJPT1QxIjAgBgkqhkiG9w0BCQEWE2V4YW1wbGVAZXhhbXBsZS5jb20xFDASBgNVBAoMC0V4YW1wbGUgT1JHMRAwDgYDVQQLDAdFeGFtcGxlMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0yMTA0MTkxMTM1MDdaFw00ODA5MDQxMTM1MDdaMIGbMR8wHQYDVQQDDBZFWEFNUExFIE1EUzMgVEVTVCBST09UMSIwIAYJKoZIhvcNAQkBFhNleGFtcGxlQGV4YW1wbGUuY29tMRQwEgYDVQQKDAtFeGFtcGxlIE9SRzEQMA4GA1UECwwHRXhhbXBsZTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDDjF5wyEWuhwDHsZosGdGFTCcI677rW881vV+UfW38J+K2ioFFNeGVsxbcebK6AVOiCDPFj0974IpeD9SFOhwAHoDu/LCfXdQWp8ZgQ91ULYWoW8o7NNSp01nbN9zmaO6/xKNCa0bzjmXoGqglqnP1AtRcWYvXOSKZy1rcPeDv4Dhcpdp6W72fBw0eWIqOhsrItuY2/N8ItBPiG03EX72nACq4nZJ/nAIcUbER8STSFPPzvE97TvShsi1FD8aO6l1WkR/QkreAGjMI++GbB2Qc1nN9Y/VEDbMDhQtxXQRdpFwubTjejkN9hKOtF3B71YrwIrng3V9RoPMFdapWMzSlI+WWHog0oTj1PqwJDDg7+z1I6vSDeVWAMKr9mq1w1OGNzgBopIjd9lRWkRtt2kQSPX9XxqS4E1gDDr8MKbpM3JuubQtNCg9D7Ljvbz6vwvUrbPHH+oREvucsp0PZ5PpizloepGIcLFxDQqCulGY2n7Ahl0JOFXJqOFCaK3TWHwBvZsaY5DgBuUvdUrwtgZNg2eg2omWXEepiVFQn3Fvj43Wh2npPMgIe5P0rwncXvROxaczd4rtajKS1ucoB9b9iKqM2+M1y/FDIgVf1fWEHwK7YdzxMlgOeLdeV/kqRU5PEUlLU9a2EwdOErrPbPKZmIfbs/L4B3k4zejMDH3Y+ZwIDAQABo1MwUTAdBgNVHQ4EFgQU8sWwq1TrurK7xMTwO1dKfeJBbCMwHwYDVR0jBBgwFoAU8sWwq1TrurK7xMTwO1dKfeJBbCMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAFw6M1PiIfCPIBQ5EBUPNmRvRFuDpolOmDofnf/+mv63LqwQZAdo/W8tzZ9kOFhq24SiLw0H7fsdG/jeREXiIZMNoW/rA6Uac8sU+FYF7Q+qp6CQLlSQbDcpVMifTQjcBk2xh+aLK9SrrXBqnTAhwS+offGtAW8DpoLuH4tAcQmIjlgMlN65jnELCuqNR/wpA+zch8LZW8saQ2cwRCwdr8mAzZoLbsDSVCHxQF3/kQjPT7Nao1q2iWcY3OYcRmKrieHDP67yeLUbVmetfZis2d6ZlkqHLB4ZW1xX4otsEFkuTJA3HWDRsNyhTwx1YoCLsYut5Zp0myqPNBq28w6qGMyyoJN0Z4RzMEO3R6i/MQNfhK55/8O2HciM6xb5t/aBSuHPKlBDrFWhpRnKYkaNtlUo35qV5IbKGKau3SdZdSRciaXUd/p81YmoF01UlhhMz/Rqr1k2gyA0a9tF8+awCeanYt5izl8YO0FlrOU1SQ5UQw4szqqZqbrf4e8fRuU2TXNx4zk+ImE7WRB44f6mSD746ZCBRogZ/SA5jUBu+OPe4/sEtERWRcQD+fXgce9ZEN0+peyJIKAsl5Rm2Bmgyg5IoyWwSG5W+WekGyEokpslou2Yc6EjUj5ndZWz5EiHAiQ74hNfDoCZIxVVLU3Qbp8a0S1bmsoT2JOsspIbtZUg="
)
const (
HeaderX509URI = "x5u"
HeaderX509Certificate = "x5c"
)
var (
errIntermediateCertRevoked = &Error{
Type: "intermediate_revoked",
Details: "Intermediate certificate is on issuers revocation list",
}
errLeafCertRevoked = &Error{
Type: "leaf_revoked",
Details: "Leaf certificate is on issuers revocation list",
}
errCRLUnavailable = &Error{
Type: "crl_unavailable",
Details: "Certificate revocation list is unavailable",
}
)
@@ -0,0 +1,290 @@
package metadata
import (
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/go-webauthn/x/revoke"
)
// NewDecoder returns a new metadata decoder.
func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) {
decoder = &Decoder{
client: &http.Client{},
parser: jwt.NewParser(),
hook: mapstructure.ComposeDecodeHookFunc(),
}
for _, opt := range opts {
if err = opt(decoder); err != nil {
return nil, fmt.Errorf("failed to apply decoder option: %w", err)
}
}
if decoder.root == "" {
decoder.root = ProductionMDSRoot
}
return decoder, nil
}
// Decoder handles decoding and specialized parsing of the metadata blob.
type Decoder struct {
client *http.Client
parser *jwt.Parser
hook mapstructure.DecodeHookFunc
root string
ignoreEntryParsingErrors bool
}
// Parse handles parsing of the raw JSON values of the metadata blob. Should be used after using [Decoder.Decode] or
// [Decoder.DecodeBytes].
func (d *Decoder) Parse(payload *PayloadJSON) (metadata *Metadata, err error) {
metadata = &Metadata{
Parsed: Parsed{
LegalHeader: payload.LegalHeader,
Number: payload.Number,
},
}
if metadata.Parsed.NextUpdate, err = time.Parse(time.DateOnly, payload.NextUpdate); err != nil {
return nil, fmt.Errorf("error occurred parsing next update value '%s': %w", payload.NextUpdate, err)
}
var parsed Entry
for _, entry := range payload.Entries {
if parsed, err = entry.Parse(); err != nil {
metadata.Unparsed = append(metadata.Unparsed, EntryError{
Error: err,
EntryJSON: entry,
})
continue
}
metadata.Parsed.Entries = append(metadata.Parsed.Entries, parsed)
}
if n := len(metadata.Unparsed); n != 0 && !d.ignoreEntryParsingErrors {
return metadata, fmt.Errorf("error occurred parsing metadata: %d entries had errors during parsing", n)
}
return metadata, nil
}
// Decode the blob from an [io.Reader]. This function will close the [io.ReadCloser] after completing.
func (d *Decoder) Decode(r io.Reader) (payload *PayloadJSON, err error) {
bytes, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return d.DecodeBytes(bytes)
}
// DecodeBytes handles decoding raw bytes. If you have a read closer it's suggested to use [Decoder.Decode].
func (d *Decoder) DecodeBytes(bytes []byte) (payload *PayloadJSON, err error) {
var token *jwt.Token
if token, err = d.parser.Parse(string(bytes), func(token *jwt.Token) (any, error) {
// 2. If the x5u attribute is present in the JWT Header.
if _, ok := token.Header[HeaderX509URI].([]any); ok {
// Never seen an x5u here, although it is in the spec.
return nil, errors.New("x5u encountered in header of metadata TOC payload")
}
// 3. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute.
var (
x5c, chain []any
ok, valid bool
)
if x5c, ok = token.Header[HeaderX509Certificate].([]any); !ok {
// If that attribute is missing as well, Metadata TOC signing trust anchor is considered the TOC signing certificate chain.
chain = []any{d.root}
} else {
chain = x5c
}
// The certificate chain MUST be verified to properly chain to the metadata TOC signing trust anchor.
if valid, err = validateChain(d.root, chain); !valid || err != nil {
return nil, err
}
// Chain validated, extract the TOC signing certificate from the chain. Create a buffer large enough to hold the
// certificate bytes.
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
var (
n int
cert *x509.Certificate
)
// Decode the base64 certificate into the buffer.
if n, err = base64.StdEncoding.Decode(o, []byte(chain[0].(string))); err != nil {
return nil, err
}
// Parse the certificate from the buffer.
if cert, err = x509.ParseCertificate(o[:n]); err != nil {
return nil, err
}
// 4. Verify the signature of the Metadata TOC object using the TOC signing certificate chain
// jwt.Parse() uses the TOC signing certificate public key internally to verify the signature.
return cert.PublicKey, err
}); err != nil {
return nil, err
}
var decoder *mapstructure.Decoder
payload = &PayloadJSON{}
if decoder, err = mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Metadata: nil,
Result: payload,
DecodeHook: d.hook,
TagName: "json",
}); err != nil {
return nil, err
}
if err = decoder.Decode(token.Claims); err != nil {
return payload, err
}
return payload, nil
}
// DecoderOption is a representation of a function that can set options within a decoder.
type DecoderOption func(decoder *Decoder) (err error)
// WithIgnoreEntryParsingErrors is a DecoderOption which ignores errors when parsing individual entries. The values for
// these entries will exist as an unparsed entry.
func WithIgnoreEntryParsingErrors() DecoderOption {
return func(decoder *Decoder) (err error) {
decoder.ignoreEntryParsingErrors = true
return nil
}
}
// WithRootCertificate overrides the root certificate used to validate the authenticity of the metadata payload.
func WithRootCertificate(value string) DecoderOption {
return func(decoder *Decoder) (err error) {
decoder.root = value
return nil
}
}
func validateChain(root string, chain []any) (bool, error) {
oRoot := make([]byte, base64.StdEncoding.DecodedLen(len(root)))
nRoot, err := base64.StdEncoding.Decode(oRoot, []byte(root))
if err != nil {
return false, err
}
rootcert, err := x509.ParseCertificate(oRoot[:nRoot])
if err != nil {
return false, err
}
roots := x509.NewCertPool()
roots.AddCert(rootcert)
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[1].(string))))
n, err := base64.StdEncoding.Decode(o, []byte(chain[1].(string)))
if err != nil {
return false, err
}
intcert, err := x509.ParseCertificate(o[:n])
if err != nil {
return false, err
}
if revoked, ok := revoke.VerifyCertificate(intcert); !ok {
issuer := intcert.IssuingCertificateURL
if issuer != nil {
return false, errCRLUnavailable
}
} else if revoked {
return false, errIntermediateCertRevoked
}
ints := x509.NewCertPool()
ints.AddCert(intcert)
l := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
n, err = base64.StdEncoding.Decode(l, []byte(chain[0].(string)))
if err != nil {
return false, err
}
leafcert, err := x509.ParseCertificate(l[:n])
if err != nil {
return false, err
}
if revoked, ok := revoke.VerifyCertificate(leafcert); !ok {
return false, errCRLUnavailable
} else if revoked {
return false, errLeafCertRevoked
}
opts := x509.VerifyOptions{
Roots: roots,
Intermediates: ints,
}
_, err = leafcert.Verify(opts)
return err == nil, err
}
func mdsParseX509Certificate(value string) (certificate *x509.Certificate, err error) {
var n int
raw := make([]byte, base64.StdEncoding.DecodedLen(len(value)))
if n, err = base64.StdEncoding.Decode(raw, []byte(strings.TrimSpace(value))); err != nil {
return nil, fmt.Errorf("error occurred parsing *x509.certificate: error occurred decoding base64 data: %w", err)
}
if certificate, err = x509.ParseCertificate(raw[:n]); err != nil {
return nil, err
}
return certificate, nil
}
func mdsParseTimePointer(format, value string) (parsed *time.Time, err error) {
if value == "" {
return nil, nil
}
var p time.Time
if p, err = time.Parse(format, value); err != nil {
return nil, err
}
return &p, nil
}
@@ -0,0 +1,2 @@
// Package metadata handles metadata validation instrumentation.
package metadata
@@ -0,0 +1,1322 @@
package metadata
import (
"crypto/x509"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// Fetch creates a new HTTP client and gets the production metadata, decodes it, and parses it. This is an
// instrumentation simplification that makes it easier to either just grab the latest metadata or for implementers to
// see the rough process of retrieving it to implement any of their own logic.
func Fetch() (metadata *Metadata, err error) {
var (
decoder *Decoder
payload *PayloadJSON
resp *http.Response
)
client := &http.Client{}
if resp, err = client.Get(ProductionMDSURL); err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("error occurred fetching metadata: status code %d", resp.StatusCode)
}
if decoder, err = NewDecoder(WithIgnoreEntryParsingErrors()); err != nil {
return nil, err
}
if payload, err = decoder.Decode(resp.Body); err != nil {
return nil, err
}
return decoder.Parse(payload)
}
// Metadata represents a FIDO Metadata Service BLOB in either a fully parsed or partially parsed state.
type Metadata struct {
// Parsed contains the successfully parsed BLOB payload entries.
Parsed Parsed
// Unparsed contains entries that failed to parse, along with their errors.
Unparsed []EntryError
}
func (m *Metadata) ToMap() (metadata map[uuid.UUID]*Entry) {
metadata = make(map[uuid.UUID]*Entry)
for _, entry := range m.Parsed.Entries {
if entry.AaGUID != uuid.Nil {
metadata[entry.AaGUID] = &entry
}
}
return metadata
}
// Parsed is a structure representing the Metadata BLOB Payload dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
type Parsed struct {
// The legalHeader, which MUST be in each BLOB, is an indication of the acceptance of the relevant legal agreement
// for using the MDS.
LegalHeader string
// The serial number of this Metadata BLOB Payload. This serial number MUST be incremented whenever the contents
// of the BLOB changes. Serial numbers MUST be consecutive and strictly monotonic, i.e. the successor BLOB will
// have a no value exactly incremented by one.
Number int
// ISO-8601 formatted date when the next update will be provided at latest. The use of this field is discouraged
// and may be removed in a future version of the spec.
NextUpdate time.Time
// List of zero or more MetadataBLOBPayloadEntry objects.
Entries []Entry
}
// PayloadJSON is an intermediary JSON/JWT representation of the Metadata BLOB Payload dictionary and the JSON
// representation of the [Parsed] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
type PayloadJSON struct {
// LegalHeader is an indication of the acceptance of the relevant legal agreement for using the MDS.
LegalHeader string `json:"legalHeader"`
// Number is the serial number of this Metadata BLOB Payload.
Number int `json:"no"`
// NextUpdate is an ISO-8601 formatted date when the next update will be provided at latest.
NextUpdate string `json:"nextUpdate"`
// Entries is a list of zero or more MetadataBLOBPayloadEntry objects.
Entries []EntryJSON `json:"entries"`
}
func (j PayloadJSON) Parse() (payload Parsed, err error) {
var update time.Time
if update, err = time.Parse(time.DateOnly, j.NextUpdate); err != nil {
return payload, fmt.Errorf("error occurred parsing next update value '%s': %w", j.NextUpdate, err)
}
n := len(j.Entries)
entries := make([]Entry, n)
for i := 0; i < n; i++ {
if entries[i], err = j.Entries[i].Parse(); err != nil {
return payload, fmt.Errorf("error occurred parsing entry %d: %w", i, err)
}
}
return Parsed{
LegalHeader: j.LegalHeader,
Number: j.Number,
NextUpdate: update,
Entries: entries,
}, nil
}
// Entry is a structure representing the Metadata BLOB Payload Entry dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
type Entry struct {
// Aaid is the AAID of the authenticator this metadata BLOB payload entry relates to. This field MUST be set if
// the authenticator implements FIDO UAF.
Aaid string
// AaGUID is the Authenticator Attestation GUID. This field MUST be set if the authenticator implements FIDO2.
AaGUID uuid.UUID
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
// hex string. This field MUST be set if neither aaid nor aaguid are set.
AttestationCertificateKeyIdentifiers []string
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
MetadataStatement Statement
// BiometricStatusReports is the status of the FIDO Biometric Certification of one or more biometric components of
// the Authenticator.
BiometricStatusReports []BiometricStatusReport
// StatusReports is an array of status reports applicable to this authenticator.
StatusReports []StatusReport
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set to the current
// value.
TimeOfLastStatusChange time.Time
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
RogueListURL *url.URL
// RogueListHash is the hash value computed over the Base64url encoding of the UTF-8 representation of the JSON
// encoded rogueList available at rogueListURL (with type rogueListEntry[]). This hash value MUST be present and
// non-empty whenever rogueListURL is present.
RogueListHash string
}
// EntryJSON is an intermediary JSON/JWT structure representing the Metadata BLOB Payload Entry dictionary and
// the JSON representation of the [Entry] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
type EntryJSON struct {
// Aaid is the AAID of the authenticator. Set if the authenticator implements FIDO UAF.
Aaid string `json:"aaid"`
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
AaGUID string `json:"aaguid"`
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
MetadataStatement StatementJSON `json:"metadataStatement"`
// BiometricStatusReports is the biometric certification status of one or more biometric components.
BiometricStatusReports []BiometricStatusReportJSON `json:"biometricStatusReports"`
// StatusReports is an array of status reports applicable to this authenticator.
StatusReports []StatusReportJSON `json:"statusReports"`
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set.
TimeOfLastStatusChange string `json:"timeOfLastStatusChange"`
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
RogueListURL string `json:"rogueListURL"`
// RogueListHash is the hash value computed over the Base64url encoding of the rogueList at rogueListURL.
RogueListHash string `json:"rogueListHash"`
}
func (j EntryJSON) Parse() (entry Entry, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error parsing AAGUID: %w", j.AaGUID, err)
}
}
var statement Statement
if statement, err = j.MetadataStatement.Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': %w", j.AaGUID, err)
}
var i, n int
n = len(j.BiometricStatusReports)
bsrs := make([]BiometricStatusReport, n)
for i = 0; i < n; i++ {
if bsrs[i], err = j.BiometricStatusReports[i].Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing biometric status report %d: %w", j.AaGUID, i, err)
}
}
n = len(j.StatusReports)
srs := make([]StatusReport, n)
for i = 0; i < n; i++ {
if srs[i], err = j.StatusReports[i].Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing status report %d: %w", j.AaGUID, i, err)
}
}
var change time.Time
if change, err = time.Parse(time.DateOnly, j.TimeOfLastStatusChange); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing time of last status change value: %w", j.AaGUID, err)
}
var rogues *url.URL
if len(j.RogueListURL) != 0 {
if rogues, err = url.ParseRequestURI(j.RogueListURL); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing rogue list URL value: %w", j.AaGUID, err)
}
if len(j.RogueListHash) == 0 {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred validating rogue list URL value: the rogue list hash was absent", j.AaGUID)
}
}
return Entry{
Aaid: j.Aaid,
AaGUID: aaguid,
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
MetadataStatement: statement,
BiometricStatusReports: bsrs,
StatusReports: srs,
TimeOfLastStatusChange: change,
RogueListURL: rogues,
RogueListHash: j.RogueListHash,
}, nil
}
// Statement is a structure representing the Metadata Statement dictionary. Authenticator metadata statements are used
// directly by the FIDO server at a relying party, but the information contained in the authoritative statement is used
// in several other places.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type Statement struct {
// The LegalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain
// URL(s) pointing to further information, such as a full Terms and Conditions statement.
LegalHeader string
// Aaid is the Authenticator Attestation ID.
Aaid string
// AaGUID is the Authenticator Attestation GUID.
AaGUID uuid.UUID
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
// hex string.
AttestationCertificateKeyIdentifiers []string
// FriendlyNames contains friendly names (i.e., public trade name) of the authenticator in multiple languages.
FriendlyNames map[string]string
// Description is a human-readable, short description of the authenticator, in English.
Description string
// AlternativeDescriptions is a list of human-readable short descriptions of the authenticator in different
// languages.
AlternativeDescriptions map[string]string
// AuthenticatorVersion is the earliest (i.e. lowest) trustworthy authenticatorVersion meeting the requirements
// specified in this metadata statement.
AuthenticatorVersion uint32
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
ProtocolFamily string
// Schema is the Metadata Schema version.
Schema uint16
// Upv is the FIDO unified protocol version(s) (related to the specific protocol family) supported by this
// authenticator.
Upv []Version
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
AuthenticationAlgorithms []AuthenticationAlgorithm
// PublicKeyAlgAndEncodings is the list of public key formats supported by the authenticator during registration
// operations.
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding
// AttestationTypes is the supported attestation type(s).
AttestationTypes AuthenticatorAttestationTypes
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
UserVerificationDetails [][]VerificationMethodDescriptor
// KeyProtection is a 16-bit number representing the bit fields defined by the KEY_PROTECTION constants in the FIDO
// Registry of Predefined Values.
KeyProtection []string
// IsKeyRestricted is set to true or it is omitted, if the Uauth private key is restricted by the authenticator to
// only sign valid FIDO signature assertions. This entry is set to false, if the authenticator doesn't restrict the
// Uauth key to only sign valid FIDO signature assertions.
IsKeyRestricted bool
// IsFreshUserVerificationRequired is set to true or it is omitted, if Uauth key usage always requires a fresh user
// verification. This entry is set to false, if the Uauth key can be used without requiring a fresh user
// verification, i.e. without any additional user interaction, if the user was verified a (potentially configurable)
// caching time ago.
IsFreshUserVerificationRequired bool
// MatcherProtection is a 16-bit number representing the bit fields defined by the MATCHER_PROTECTION constants in
// the FIDO Registry of Predefined Values.
MatcherProtection []string
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits (sometimes also called
// security strength or security level).
CryptoStrength uint16
// AttachmentHint is a 32-bit number representing the bit fields defined by the ATTACHMENT_HINT constants in the
// FIDO Registry of Predefined Values.
AttachmentHint []string
// TcDisplay is a 16-bit number representing a combination of the bit flags defined by the
// TRANSACTION_CONFIRMATION_DISPLAY constants in the FIDO Registry of Predefined Values.
TcDisplay []string
// TcDisplayContentType is the supported MIME content type [RFC2049] for the transaction confirmation display, such
// as text/plain or image/png.
TcDisplayContentType string
// TcDisplayPNGCharacteristics is a list of alternative [DisplayPNGCharacteristicsDescriptor]. Each of these entries
// is one alternative of supported image characteristics for displaying a PNG image.
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor
// AttestationRootCertificates is a list of root certificates. Each element of this array represents a PKIX
// [RFC5280] X.509 certificate that is a valid trust anchor for this authenticator model.
// Multiple certificates might be used for different batches of the same model.
// The array does not represent a certificate chain, but only the trust anchor of that chain.
// A trust anchor can be a root certificate, an intermediate CA certificate, or even the attestation certificate
// itself.
AttestationRootCertificates []*x509.Certificate
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation. This entry MUST be present if and only
// if attestationType includes ATTESTATION_ECDAA.
EcdaaTrustAnchors []EcdaaTrustAnchor
// Icon is a 'data:' url [RFC2397] encoded [PNG] or [SVG11] (light mode) icon for the Authenticator (i.e., depicting
// the security key). This icon is intended to be shown to users by RPs. Use of [SVG11] format is mandatory if any
// of the iconDark, providerLogoLight and/or providerLogoDark is used in addition to icon. Use of [SVG11] is
// recommended if only icon is used. The icon is more specific than the provider logo and should be shown if
// present.
Icon *url.URL
// IconDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the Authenticator (i.e., depicting the
// security key). This icon is intended to be shown to users by RPs. The icon is more specific than the provider
// logo and should be shown if present.
IconDark *url.URL
// ProviderLogoLight is a 'data:' url [RFC2397] encoded [SVG11] light mode icon for the provider (i.e., logomark of
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
// is intended to be shown to users by RPs.
ProviderLogoLight *url.URL
// ProviderLogoDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the provider (i.e., logomark of
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
// is intended to be shown to users by RPs.
ProviderLogoDark *url.URL
// SupportedExtensions is a list of extensions supported by the authenticator.
SupportedExtensions []ExtensionDescriptor
// KeyScope of keys generated and maintained by this authenticator model.
KeyScope KeyScope
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
MultiDeviceCredentialSupport MultiDeviceCredentialSupport
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
AuthenticatorGetInfo AuthenticatorGetInfo
// CredentialExportProtocolConfigURL specifies the URL for retrieving the configuration details for the credential
// export protocol (CXP).
CredentialExportProtocolConfigURL *url.URL
}
func (s *Statement) Verifier(x5cis []*x509.Certificate) (opts x509.VerifyOptions) {
roots := x509.NewCertPool()
for _, root := range s.AttestationRootCertificates {
roots.AddCert(root)
}
var intermediates *x509.CertPool
if len(x5cis) > 0 {
intermediates = x509.NewCertPool()
for _, x5c := range x5cis {
intermediates.AddCert(x5c)
}
}
return x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
}
}
// StatementJSON is the JSON representation of the [Statement] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type StatementJSON struct {
// LegalHeader contains a legal guide for accessing and using metadata.
LegalHeader string `json:"legalHeader"`
// Aaid is the Authenticator Attestation ID. Set if the authenticator implements FIDO UAF.
Aaid string `json:"aaid"`
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
AaGUID string `json:"aaguid"`
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
// FriendlyNames contains friendly names of the authenticator in multiple languages.
FriendlyNames map[string]string `json:"friendlyNames"`
// Description is a human-readable, short description of the authenticator, in English.
Description string `json:"description"`
// AlternativeDescriptions is a list of human-readable short descriptions in different languages.
AlternativeDescriptions map[string]string `json:"alternativeDescriptions"`
// AuthenticatorVersion is the earliest trustworthy authenticatorVersion meeting the requirements in this statement.
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
ProtocolFamily string `json:"protocolFamily"`
// Schema is the Metadata Schema version.
Schema uint16 `json:"schema"`
// Upv is the FIDO unified protocol version(s) supported by this authenticator.
Upv []Version `json:"upv"`
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
AuthenticationAlgorithms []AuthenticationAlgorithm `json:"authenticationAlgorithms"`
// PublicKeyAlgAndEncodings is the list of public key formats supported during registration operations.
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding `json:"publicKeyAlgAndEncodings"`
// AttestationTypes is the supported attestation type(s).
AttestationTypes []AuthenticatorAttestationType `json:"attestationTypes"`
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
UserVerificationDetails [][]VerificationMethodDescriptor `json:"userVerificationDetails"`
// KeyProtection is the key protection type(s).
KeyProtection []string `json:"keyProtection"`
// IsKeyRestricted indicates if the Uauth private key is restricted to only sign valid FIDO signature assertions.
IsKeyRestricted bool `json:"isKeyRestricted"`
// IsFreshUserVerificationRequired indicates if Uauth key usage always requires a fresh user verification.
IsFreshUserVerificationRequired bool `json:"isFreshUserVerificationRequired"`
// MatcherProtection is the matcher protection type(s).
MatcherProtection []string `json:"matcherProtection"`
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits.
CryptoStrength uint16 `json:"cryptoStrength"`
// AttachmentHint is the attachment hint(s).
AttachmentHint []string `json:"attachmentHint"`
// TcDisplay is the transaction confirmation display type(s).
TcDisplay []string `json:"tcDisplay"`
// TcDisplayContentType is the supported MIME content type for the transaction confirmation display.
TcDisplayContentType string `json:"tcDisplayContentType"`
// TcDisplayPNGCharacteristics is a list of alternative DisplayPNGCharacteristicsDescriptor.
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor `json:"tcDisplayPNGCharacteristics"`
// AttestationRootCertificates is a list of base64-encoded trust anchor certificates for this authenticator model.
AttestationRootCertificates []string `json:"attestationRootCertificates"`
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation.
EcdaaTrustAnchors []EcdaaTrustAnchor `json:"ecdaaTrustAnchors"`
// Icon is a data: URL encoded PNG or SVG (light mode) icon for the Authenticator.
Icon string `json:"icon"`
// IconDark is a data: URL encoded SVG dark mode icon for the Authenticator.
IconDark string `json:"iconDark"`
// ProviderLogoLight is a data: URL encoded SVG light mode icon for the provider.
ProviderLogoLight string `json:"providerLogoLight"`
// ProviderLogoDark is a data: URL encoded SVG dark mode icon for the provider.
ProviderLogoDark string `json:"providerLogoDark"`
// SupportedExtensions is a list of extensions supported by the authenticator.
SupportedExtensions []ExtensionDescriptor `json:"supportedExtensions"`
// KeyScope of keys generated and maintained by this authenticator model.
KeyScope KeyScope `json:"keyScope"`
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
MultiDeviceCredentialSupport MultiDeviceCredentialSupport `json:"multiDeviceCredentialSupport"`
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
AuthenticatorGetInfo AuthenticatorGetInfoJSON `json:"authenticatorGetInfo"`
// CredentialExportProtocolConfigURL specifies the URL for the credential export protocol (CXP) configuration.
CredentialExportProtocolConfigURL string `json:"cxpConfigURL"`
}
// Parse converts StatementJSON into a [Statement] object, validating and parsing its fields. Returns an error on failure.
//
//nolint:gocyclo
func (j StatementJSON) Parse() (statement Statement, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing AAGUID value: %w", j.Description, err)
}
}
n := len(j.AttestationRootCertificates)
certificates := make([]*x509.Certificate, n)
for i := 0; i < n; i++ {
if certificates[i], err = mdsParseX509Certificate(j.AttestationRootCertificates[i]); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing attestation root certificate %d value: %w", j.Description, i, err)
}
}
var (
icon, iconDark *url.URL
logoLight, logoDark *url.URL
cxpConfigURL *url.URL
)
if len(j.Icon) != 0 {
if icon, err = url.ParseRequestURI(j.Icon); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon value: %w", j.Description, err)
}
}
if len(j.IconDark) != 0 {
if iconDark, err = url.ParseRequestURI(j.IconDark); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon dark value: %w", j.Description, err)
}
}
if len(j.ProviderLogoLight) != 0 {
if logoLight, err = url.ParseRequestURI(j.ProviderLogoLight); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo light value: %w", j.Description, err)
}
}
if len(j.ProviderLogoDark) != 0 {
if logoDark, err = url.ParseRequestURI(j.ProviderLogoDark); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo dark value: %w", j.Description, err)
}
}
if len(j.CredentialExportProtocolConfigURL) != 0 {
if cxpConfigURL, err = url.ParseRequestURI(j.CredentialExportProtocolConfigURL); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing cxp config url value: %w", j.Description, err)
}
}
var info AuthenticatorGetInfo
if info, err = j.AuthenticatorGetInfo.Parse(); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing authenticator get info value: %w", j.Description, err)
}
return Statement{
LegalHeader: j.LegalHeader,
Aaid: j.Aaid,
AaGUID: aaguid,
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
FriendlyNames: j.FriendlyNames,
Description: j.Description,
AlternativeDescriptions: j.AlternativeDescriptions,
AuthenticatorVersion: j.AuthenticatorVersion,
ProtocolFamily: j.ProtocolFamily,
Schema: j.Schema,
Upv: j.Upv,
AuthenticationAlgorithms: j.AuthenticationAlgorithms,
PublicKeyAlgAndEncodings: j.PublicKeyAlgAndEncodings,
AttestationTypes: j.AttestationTypes,
UserVerificationDetails: j.UserVerificationDetails,
KeyProtection: j.KeyProtection,
IsKeyRestricted: j.IsKeyRestricted,
IsFreshUserVerificationRequired: j.IsFreshUserVerificationRequired,
MatcherProtection: j.MatcherProtection,
CryptoStrength: j.CryptoStrength,
AttachmentHint: j.AttachmentHint,
TcDisplay: j.TcDisplay,
TcDisplayContentType: j.TcDisplayContentType,
TcDisplayPNGCharacteristics: j.TcDisplayPNGCharacteristics,
AttestationRootCertificates: certificates,
EcdaaTrustAnchors: j.EcdaaTrustAnchors,
Icon: icon,
IconDark: iconDark,
ProviderLogoLight: logoLight,
ProviderLogoDark: logoDark,
SupportedExtensions: j.SupportedExtensions,
KeyScope: j.KeyScope,
MultiDeviceCredentialSupport: j.MultiDeviceCredentialSupport,
AuthenticatorGetInfo: info,
CredentialExportProtocolConfigURL: cxpConfigURL,
}, nil
}
// BiometricStatusReport is a structure representing the BiometricStatusReport dictionary. Contains the current
// BiometricStatusReport of one of the authenticator's biometric component.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
type BiometricStatusReport struct {
// CertLevel is the achieved level of the biometric certification of this biometric component of the authenticator.
CertLevel uint16
// Modality is a single USER_VERIFY short form case-sensitive string name constant, representing biometric modality.
Modality string
// EffectiveDate is an ISO-8601 formatted date since when the certLevel achieved, if applicable. If no date is
// given, the status is assumed to be effective while present.
EffectiveDate time.Time
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
CertificationDescriptor string
// CertificateNumber is the unique identifier for the issued Biometric Certification.
CertificateNumber string
// CertificationPolicyVersion is the version of the Biometric Certification Policy the implementation is Certified
// to, i.e. "1.0.0".
CertificationPolicyVersion string
// CertificationRequirementsVersion is the version of the Biometric Requirements [FIDOBiometricsRequirements] the
// implementation is certified to, i.e. "1.0.0".
CertificationRequirementsVersion string
}
// BiometricStatusReportJSON is the JSON representation of the [BiometricStatusReport] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
type BiometricStatusReportJSON struct {
// CertLevel is the achieved level of the biometric certification of this biometric component.
CertLevel uint16 `json:"certLevel"`
// Modality is a single USER_VERIFY short form string constant representing the biometric modality.
Modality string `json:"modality"`
// EffectiveDate is an ISO-8601 formatted date since when the certLevel was achieved.
EffectiveDate string `json:"effectiveDate"`
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
CertificationDescriptor string `json:"certificationDescriptor"`
// CertificateNumber is the unique identifier for the issued Biometric Certification.
CertificateNumber string `json:"certificateNumber"`
// CertificationPolicyVersion is the version of the Biometric Certification Policy, i.e. "1.0.0".
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
// CertificationRequirementsVersion is the version of the Biometric Requirements, i.e. "1.0.0".
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
}
func (j BiometricStatusReportJSON) Parse() (report BiometricStatusReport, err error) {
var effective time.Time
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
}
return BiometricStatusReport{
CertLevel: j.CertLevel,
Modality: j.Modality,
EffectiveDate: effective,
CertificationDescriptor: j.CertificationDescriptor,
CertificateNumber: j.CertificateNumber,
CertificationPolicyVersion: j.CertificationPolicyVersion,
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
}, nil
}
// StatusReport is a structure representing the StatusReport dictionary. Contains an [AuthenticatorStatus] and additional
// data associated with it, if any.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
type StatusReport struct {
// Status of the authenticator. Additional fields MAY be set depending on this value.
Status AuthenticatorStatus
// EffectiveDate is an ISO-8601 formatted date since when the status code was set, if applicable. If no date is
// given, the status is assumed to be effective while present.
EffectiveDate time.Time
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to. In the
// case of FIDO_CERTIFIED* status values, the status applies to higher authenticatorVersions until there is a new
// statusReport.
AuthenticatorVersion uint32
// BatchCertificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value
// related to the current status, if applicable.
BatchCertificate *x509.Certificate
// Certificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value related to
// the current status, if applicable. This field will typically not be present if field batchCertificate is present.
Certificate *x509.Certificate
// URL is a HTTPS URL where additional information may be found related to the current status, if applicable.
URL *url.URL
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
CertificationDescriptor string
// CertificateNumber is the unique identifier for the issued Certification.
CertificateNumber string
// CertificationPolicyVersion is the version of the Authenticator Certification Policy the implementation is
// Certified to, i.e. "1.0.0".
CertificationPolicyVersion string
// CertificationProfiles is a list of certification profile strings. Each entry represents a supported
// certification profile, i.e. "consumer" or "enterprise".
CertificationProfiles []string
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements (DV)
// [FIDOAuthenticatorSecurityRequirements] the implementation is certified to, i.e. "1.2.0".
CertificationRequirementsVersion string
// SunsetDate is an ISO-8601 formatted date since when the status will expire, if applicable. If no date is given,
// the status is assumed to not have a scheduled expiry.
SunsetDate *time.Time
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" in the case of FIPS 140-3. This
// entry MUST be present if and only if the status entry is one of FIPS140_CERTIFIED_L*.
FIPSRevision uint32
// FIPSPhysicalSecurityLevel is the "physical security level" of the FIPS certification. This entry MUST be present
// if and only if the status entry is one of FIPS140_CERTIFIED_L*. It MUST reflect the physical security level
// which might deviate from the overall level.
FIPSPhysicalSecurityLevel uint32
}
// StatusReportJSON is the JSON representation of the [StatusReport] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
type StatusReportJSON struct {
// Status of the authenticator. Additional fields MAY be set depending on this value.
Status AuthenticatorStatus `json:"status"`
// EffectiveDate is an ISO-8601 formatted date since when the status code was set.
EffectiveDate string `json:"effectiveDate"`
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to.
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
// BatchCertificate is a Base64-encoded DER PKIX certificate related to the current status.
BatchCertificate string `json:"batchCertificate"`
// Certificate is a Base64-encoded DER PKIX certificate related to the current status.
Certificate string `json:"certificate"`
// URL is a HTTPS URL where additional information may be found related to the current status.
URL string `json:"url"`
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
CertificationDescriptor string `json:"certificationDescriptor"`
// CertificateNumber is the unique identifier for the issued Certification.
CertificateNumber string `json:"certificateNumber"`
// CertificationPolicyVersion is the version of the Authenticator Certification Policy, i.e. "1.0.0".
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
// CertificationProfiles is a list of supported certification profiles, i.e. "consumer" or "enterprise".
CertificationProfiles []string `json:"certificationProfiles"`
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements, i.e. "1.2.0".
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
// SunsetDate is an ISO-8601 formatted date when the status will expire.
SunsetDate string `json:"sunsetDate"`
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" for FIPS 140-3.
FIPSRevision uint32 `json:"fipsRevision"`
// FIPSPhysicalSecurityLevel is the physical security level of the FIPS certification.
FIPSPhysicalSecurityLevel uint32 `json:"fipsPhysicalSecurityLevel"`
}
func (j StatusReportJSON) Parse() (report StatusReport, err error) {
var (
certificate, batchCertificate *x509.Certificate
)
if len(j.Certificate) != 0 {
if certificate, err = mdsParseX509Certificate(j.Certificate); err != nil {
return report, fmt.Errorf("error occurred parsing certificate value: %w", err)
}
}
if len(j.BatchCertificate) != 0 {
if batchCertificate, err = mdsParseX509Certificate(j.BatchCertificate); err != nil {
return report, fmt.Errorf("error occurred parsing batch certificate value: %w", err)
}
}
var (
effective time.Time
sunset *time.Time
)
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
}
if sunset, err = mdsParseTimePointer(time.DateOnly, j.SunsetDate); err != nil {
return report, fmt.Errorf("error occurred parsing sunset date value: %w", err)
}
var uri *url.URL
if len(j.URL) != 0 {
if uri, err = url.ParseRequestURI(j.URL); err != nil {
if !strings.HasPrefix(j.URL, "http") {
var e error
if uri, e = url.ParseRequestURI(fmt.Sprintf("https://%s", j.URL)); e != nil {
return report, fmt.Errorf("error occurred parsing URL value: %w", err)
}
}
}
}
return StatusReport{
Status: j.Status,
EffectiveDate: effective,
AuthenticatorVersion: j.AuthenticatorVersion,
BatchCertificate: batchCertificate,
Certificate: certificate,
URL: uri,
CertificationDescriptor: j.CertificationDescriptor,
CertificateNumber: j.CertificateNumber,
CertificationPolicyVersion: j.CertificationPolicyVersion,
CertificationProfiles: j.CertificationProfiles,
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
SunsetDate: sunset,
FIPSRevision: j.FIPSRevision,
FIPSPhysicalSecurityLevel: j.FIPSPhysicalSecurityLevel,
}, nil
}
// RogueListEntry is a structure representing the RogueListEntry dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-rogue-list-entry
type RogueListEntry struct {
// Sk is the base64url encoding of the rogue authenticator's secret key.
Sk string `json:"sk"`
// Data is the ISO-8601 formatted date since when this entry is effective.
Date string `json:"date"`
}
// CodeAccuracyDescriptor is a structure representing the CodeAccuracyDescriptor dictionary.
// It describes the relevant accuracy/complexity aspects of passcode user verification methods.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-cad
type CodeAccuracyDescriptor struct {
// Base is the numeric system base (radix) of the code, i.e. 10 in the case of decimal digits.
Base uint16 `json:"base"`
// MinLength is the minimum number of digits of the given base required for that code, i.e. 4 in the case of 4
// digits.
MinLength uint16 `json:"minLength"`
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
// some time). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
// similar). 0 means this user verification method will be blocked, either permanently, or until an alternative user
// verification method method succeeded. All alternative user verification methods MUST be specified appropriately
// in the Metadata in userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// BiometricAccuracyDescriptor is a structure representing the BiometricAccuracyDescriptor dictionary.
// It describes relevant accuracy/complexity aspects in the case of a biometric user verification method.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-bad
type BiometricAccuracyDescriptor struct {
// SelfAttestedFRR is the false rejection rate [ISO19795-1] for a single template, i.e. the percentage of
// verification transactions with truthful claims of identity that are incorrectly denied.
SelfAttestedFRR float64 `json:"selfAttestedFRR"`
// SelfAttestedFAR is the false acceptance rate [ISO19795-1] for a single template, i.e. the percentage of
// verification transactions with wrongful claims of identity that are incorrectly confirmed.
SelfAttestedFAR float64 `json:"selfAttestedFAR"`
// ImposterAttackPresentationAcceptRateThreshold is the threshold for Impostor Attack Presentation Accept Rate
// (IAPAR) is the proportion of impostor attack presentations using the same presentation attack instrument (PAI)
// species that result in accept [isoiec-30107-3]. For biometric certification requirements
// [FIDOBiometricsRequirements], certification can be achieved for an IAPAR threshold of less than 7% OR less than
// 15% for each of the PAI species tested.
ImposterAttackPresentationAcceptRateThreshold float64 `json:"iAPARThreshold"`
// MaxTemplates is the maximum number of alternative templates from different fingers allowed.
MaxTemplates uint16 `json:"maxTemplates"`
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
// some time). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
// similar).0 means that this user verification method will be blocked either permanently or until an alternative
// user verification method succeeded. All alternative user verification methods MUST be specified appropriately in
// the metadata in userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// PatternAccuracyDescriptor is a structure representing the PatternAccuracyDescriptor dictionary.
// It describes relevant accuracy/complexity aspects in the case that a pattern is used as the user verification method.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-pad
type PatternAccuracyDescriptor struct {
// MinComplexity is the number of possible patterns (having the minimum length) out of which exactly one would be
// the right one, i.e. 1/probability in the case of equal distribution.
MinComplexity uint32 `json:"minComplexity"`
// MaxRetries is the maximum number of false attempts before the authenticator will block authentication using this
// method (at least temporarily). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (due to forced reboot or similar
// mechanism). 0 means this user verification method will be blocked, either permanently, or until an alternative
// user verification method method succeeded. All alternative user verification methods MUST be specified
// appropriately in the metadata under userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// VerificationMethodDescriptor is a structure representing the VerificationMethodDescriptor dictionary.
// It describes a descriptor for a specific base user verification method as implemented by the authenticator.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-vmd
type VerificationMethodDescriptor struct {
// UserVerificationMethod is a single USER_VERIFY constant (see [FIDORegistry]), not a bit flag combination. This
// value MUST be non-zero.
UserVerificationMethod string `json:"userVerificationMethod"`
// CaDesc nay optionally be used in the case of method USER_VERIFY_PASSCODE.
CaDesc CodeAccuracyDescriptor `json:"caDesc"`
// BaDesc may optionally be used in the case of method USER_VERIFY_FINGERPRINT, USER_VERIFY_VOICEPRINT,
// USER_VERIFY_FACEPRINT, USER_VERIFY_EYEPRINT, or USER_VERIFY_HANDPRINT.
BaDesc BiometricAccuracyDescriptor `json:"baDesc"`
// PaDesc may optionally be used in case of method USER_VERIFY_PATTERN.
PaDesc PatternAccuracyDescriptor `json:"paDesc"`
}
// RGBPaletteEntry is a structure representing the RGBPaletteEntry dictionary.
// It describes an RGB three-sample tuple palette entry.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-rgbpe
type RGBPaletteEntry struct {
// R is the red channel sample value.
R uint16 `json:"r"`
// G is the green channel sample value.
G uint16 `json:"g"`
// B is the blue channel sample value.
B uint16 `json:"b"`
}
// DisplayPNGCharacteristicsDescriptor is a structure representing the DisplayPNGCharacteristicsDescriptor MDS3.1
// dictionary. It describes a PNG image characteristics as defined in the PNG [PNG] spec for IHDR (image header) and
// PLTE (palette table).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-dpngcd
type DisplayPNGCharacteristicsDescriptor struct {
// Width of the image.
Width uint32 `json:"width"`
// Height of the image.
Height uint32 `json:"height"`
// BitDepth is bits per sample or per palette index.
BitDepth byte `json:"bitDepth"`
// ColorType defines the PNG image type.
ColorType byte `json:"colorType"`
// Compression method used to compress the image data.
Compression byte `json:"compression"`
// Filter method is the preprocessing method applied to the image data before compression.
Filter byte `json:"filter"`
// Interlace method is the transmission order of the image data.
Interlace byte `json:"interlace"`
// Plte is a number 1 to 256 representing palette entries.
Plte []RGBPaletteEntry `json:"plte"`
}
// EcdaaTrustAnchor is a structure representing the EcdaaTrustAnchor dictionary.
// In the case of ECDAA attestation, the ECDAA-Issuer's trust anchor MUST be specified in this field.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ecdaata
type EcdaaTrustAnchor struct {
// X is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 X.
X string `json:"X"`
// Y is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 Y.
Y string `json:"Y"`
// C is the base64url encoding of the result of BigNumberToB(c).
C string `json:"c"`
// SX is the base64url encoding of the result of BigNumberToB(sx).
SX string `json:"sx"`
// SY is the base64url encoding of the result of BigNumberToB(sy).
SY string `json:"sy"`
// G1Curve is the name of the Barreto-Naehrig elliptic curve for G1. "BN_P256", "BN_P638", "BN_ISOP256", and
// "BN_ISOP512" are supported.
G1Curve string `json:"G1Curve"`
}
// ExtensionDescriptor is a structure representing the ExtensionDescriptor dictionary.
// This descriptor contains an extension supported by the authenticator.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ed
type ExtensionDescriptor struct {
// ID identifies the extension.
ID string `json:"id"`
// Tag of the extension if this was assigned. TAGs are assigned to extensions if they could appear in an assertion.
Tag uint16 `json:"tag"`
// Data contains arbitrary data further describing the extension and/or data needed to correctly process the
// extension.
Data string `json:"data"`
// FailIfUnknown indicates whether unknown extensions must be ignored (false) or must lead to an error (true) when
// the extension is to be processed by the FIDO Server, FIDO Client, ASM, or FIDO Authenticator.
FailIfUnknown bool `json:"fail_if_unknown"`
}
// Version is a structure representing the Version FIDO UAF Protocol 1.2 dictionary and represents a generic version
// with major and minor fields.
//
// See: https://fidoalliance.org/specs/fido-uaf-v1.2-ps-20201020/fido-uaf-protocol-v1.2-ps-20201020.html#version-interface
type Version struct {
// Major version.
Major uint16 `json:"major"`
// Minor version.
Minor uint16 `json:"minor"`
}
// AuthenticatorGetInfo is a structure representing the AuthenticatorGetInfo dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
type AuthenticatorGetInfo struct {
// Versions is a list of supported versions.
Versions []string
// Extensions is a list of supported extensions.
Extensions []string
// AaGUID is the claimed AAGUID.
AaGUID uuid.UUID
// Options is a list of supported options.
Options map[string]bool
// MaxMsgSize is the maximum message size supported by the authenticator.
MaxMsgSize uint
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
PivUvAuthProtocols []uint
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time by the
// authenticator.
MaxCredentialCountInList uint
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
MaxCredentialIdLength uint
// Transports is the list of supported transports.
Transports []string
// Algorithms is the list of supported algorithms for credential generation, as specified in WebAuthn.
Algorithms []PublicKeyCredentialParameters
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array that this
// authenticator can store.
MaxSerializedLargeBlobArray uint
// ForcePINChange indicates if the PIN must be changed.
ForcePINChange bool
// MinPINLength specifies the current minimum PIN length, in Unicode code points, the authenticator enforces for ClientPIN.
MinPINLength uint
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
FirmwareVersion uint
// MaxCredBlobLength indicates the maximum credential blob length in bytes supported by the authenticator.
MaxCredBlobLength uint
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that authenticator can set via setMinPINLength
// subcommand.
MaxRPIDsForSetMinPINLength uint
// PreferredPlatformUvAttempts specifies the preferred number of invocations of the
// getPinUvAuthTokenUsingUvWithPermissions subCommand the platform may attempt before falling back to the
// getPinUvAuthTokenUsingPinWithPermissions subCommand or displaying an error.
PreferredPlatformUvAttempts uint
// UvModality specifies the user verification modality supported by the authenticator via authenticatorClientPIN's
// getPinUvAuthTokenUsingUvWithPermissions subcommand.
UvModality uint
// Certifications specifies a list of authenticator certifications.
Certifications map[string]float64
// RemainingDiscoverableCredentials if present indicates the estimated number of additional discoverable credentials
// that can be stored.
RemainingDiscoverableCredentials uint
// VendorPrototypeConfigCommands if present the authenticator supports the authenticatorConfig vendorPrototype
// subcommand, and its value is a list of authenticatorConfig vendorCommandId values supported, which MAY be empty.
VendorPrototypeConfigCommands []uint
}
// AuthenticatorGetInfoJSON is the JSON representation of the [AuthenticatorGetInfo] struct. The members mirror the
// fields returned by the CTAP authenticatorGetInfo command.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
type AuthenticatorGetInfoJSON struct {
// Versions is a list of supported CTAP versions.
Versions []string `json:"versions"`
// Extensions is a list of supported extensions.
Extensions []string `json:"extensions"`
// AaGUID is the claimed AAGUID.
AaGUID string `json:"aaguid"`
// Options is a map of supported options.
Options map[string]bool `json:"options"`
// MaxMsgSize is the maximum message size supported by the authenticator.
MaxMsgSize uint `json:"maxMsgSize"`
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
PivUvAuthProtocols []uint `json:"pinUvAuthProtocols"`
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time.
MaxCredentialCountInList uint `json:"maxCredentialCountInList"`
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
MaxCredentialIdLength uint `json:"maxCredentialIdLength"`
// Transports is the list of supported transports.
Transports []string `json:"transports"`
// Algorithms is the list of supported algorithms for credential generation.
Algorithms []PublicKeyCredentialParameters `json:"algorithms"`
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array.
MaxSerializedLargeBlobArray uint `json:"maxSerializedLargeBlobArray"`
// ForcePINChange indicates if the PIN must be changed.
ForcePINChange bool `json:"forcePINChange"`
// MinPINLength specifies the current minimum PIN length, in Unicode code points.
MinPINLength uint `json:"minPINLength"`
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
FirmwareVersion uint `json:"firmwareVersion"`
// MaxCredBlobLength indicates the maximum credential blob length in bytes.
MaxCredBlobLength uint `json:"maxCredBlobLength"`
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that can be set via setMinPINLength subcommand.
MaxRPIDsForSetMinPINLength uint `json:"maxRPIDsForSetMinPINLength"`
// PreferredPlatformUvAttempts specifies the preferred number of UV attempts before falling back to PIN.
PreferredPlatformUvAttempts uint `json:"preferredPlatformUvAttempts"`
// UvModality specifies the user verification modality supported by the authenticator.
UvModality uint `json:"uvModality"`
// Certifications specifies a map of authenticator certifications.
Certifications map[string]float64 `json:"certifications"`
// RemainingDiscoverableCredentials indicates the estimated number of additional discoverable credentials that
// can be stored.
RemainingDiscoverableCredentials uint `json:"remainingDiscoverableCredentials"`
// VendorPrototypeConfigCommands is a list of supported authenticatorConfig vendorCommandId values.
VendorPrototypeConfigCommands []uint `json:"vendorPrototypeConfigCommands"`
}
func (j AuthenticatorGetInfoJSON) Parse() (info AuthenticatorGetInfo, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return info, fmt.Errorf("error occurred parsing AAGUID value: %w", err)
}
}
return AuthenticatorGetInfo{
Versions: j.Versions,
Extensions: j.Extensions,
AaGUID: aaguid,
Options: j.Options,
MaxMsgSize: j.MaxMsgSize,
PivUvAuthProtocols: j.PivUvAuthProtocols,
MaxCredentialCountInList: j.MaxCredentialCountInList,
MaxCredentialIdLength: j.MaxCredentialIdLength,
Transports: j.Transports,
Algorithms: j.Algorithms,
MaxSerializedLargeBlobArray: j.MaxSerializedLargeBlobArray,
ForcePINChange: j.ForcePINChange,
MinPINLength: j.MinPINLength,
FirmwareVersion: j.FirmwareVersion,
MaxCredBlobLength: j.MaxCredBlobLength,
MaxRPIDsForSetMinPINLength: j.MaxRPIDsForSetMinPINLength,
PreferredPlatformUvAttempts: j.PreferredPlatformUvAttempts,
UvModality: j.UvModality,
Certifications: j.Certifications,
RemainingDiscoverableCredentials: j.RemainingDiscoverableCredentials,
VendorPrototypeConfigCommands: j.VendorPrototypeConfigCommands,
}, nil
}
// MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint.
type MDSGetEndpointsRequest struct {
// Endpoint is the URL of the local server endpoint, i.e. https://webauthn.io/
Endpoint string `json:"endpoint"`
}
// MDSGetEndpointsResponse is the response received from a conformance metadata getEndpoints request.
type MDSGetEndpointsResponse struct {
// Status is the status of the response.
Status string `json:"status"`
// Result is an array of urls, each pointing to a MetadataTOCPayload.
Result []string `json:"result"`
}
// DefaultUndesiredAuthenticatorStatuses returns a copy of the defaultUndesiredAuthenticatorStatus slice.
func DefaultUndesiredAuthenticatorStatuses() []AuthenticatorStatus {
undesired := make([]AuthenticatorStatus, len(defaultUndesiredAuthenticatorStatus))
copy(undesired, defaultUndesiredAuthenticatorStatus[:])
return undesired
}
// EntryError represents an [EntryJSON] that failed to parse, along with the error that occurred.
type EntryError struct {
// Error is the parsing error that occurred.
Error error
// EntryJSON is the raw JSON entry that failed to parse.
EntryJSON
}
@@ -0,0 +1,16 @@
package metadata
// PasskeyAuthenticator is a type that represents the schema from the Passkey Developer AAGUID listing.
//
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
type PasskeyAuthenticator map[string]PassKeyAuthenticatorAAGUID
// PassKeyAuthenticatorAAGUID is a type that represents the individual schema entry from the Passkey Developer AAGUID
// listing. Used with [PasskeyAuthenticator].
//
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
type PassKeyAuthenticatorAAGUID struct {
Name string `json:"name"`
IconDark string `json:"icon_dark,omitempty"`
IconLight string `json:"icon_light,omitempty"`
}
@@ -0,0 +1,64 @@
package metadata
import (
"fmt"
"strings"
)
// ValidateStatusReports checks a list of [StatusReport] structs against a list of desired and undesired [AuthenticatorStatus]
// values. If the reports contain all of the desired and none of the undesired status reports then no error is returned
// otherwise an error describing the issue is returned.
//
//nolint:gocyclo
func ValidateStatusReports(reports []StatusReport, desired, undesired []AuthenticatorStatus) (err error) {
if len(desired) == 0 && (len(undesired) == 0 || len(reports) == 0) {
return nil
}
var present, absent []string
if len(undesired) != 0 {
for _, report := range reports {
for _, status := range undesired {
if report.Status == status {
present = append(present, string(status))
continue
}
}
}
}
if len(desired) != 0 {
desired:
for _, status := range desired {
for _, report := range reports {
if report.Status == status {
continue desired
}
}
absent = append(absent, string(status))
}
}
switch {
case len(present) == 0 && len(absent) == 0:
return nil
case len(present) != 0 && len(absent) == 0:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following undesired status reports were present: %s", strings.Join(present, ", ")),
}
case len(present) == 0 && len(absent) != 0:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following desired status reports were absent: %s", strings.Join(absent, ", ")),
}
default:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following undesired status reports were present: %s; the following desired status reports were absent: %s", strings.Join(present, ", "), strings.Join(absent, ", ")),
}
}
}
+420
View File
@@ -0,0 +1,420 @@
package metadata
import (
"context"
"errors"
"reflect"
"time"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// The Provider is an interface which describes the elements required to satisfy validation of metadata.
type Provider interface {
// GetEntry returns a MDS3 payload entry given a AAGUID.
GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *Entry, err error)
// GetValidateEntry returns true if this provider requires an entry to exist with a AAGUID matching the attestation
// statement during registration.
GetValidateEntry(ctx context.Context) (validate bool)
// GetValidateEntryPermitZeroAAGUID returns true if attestation statements with zerod AAGUID should be permitted
// when considering the result from GetValidateEntry. i.e. if the AAGUID is zeroed, and GetValidateEntry returns
// true, and this implementation returns true, the attestation statement will pass validation.
GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool)
// GetValidateTrustAnchor returns true if trust anchor validation of attestation statements is enforced during
// registration.
GetValidateTrustAnchor(ctx context.Context) (validate bool)
// GetValidateStatus returns true if the status reports for an authenticator should be validated against desired and
// undesired statuses.
GetValidateStatus(ctx context.Context) (validate bool)
// GetValidateAttestationTypes if true will enforce checking that the provided attestation is possible with the
// given authenticator.
GetValidateAttestationTypes(ctx context.Context) (validate bool)
// ValidateStatusReports returns nil if the provided authenticator status reports are desired.
ValidateStatusReports(ctx context.Context, reports []StatusReport) (err error)
}
var (
ErrNotInitialized = errors.New("metadata: not initialized")
)
// PublicKeyCredentialParameters describes a credential type and algorithm pair per the WebAuthn specification. It is
// used in [AuthenticatorGetInfo] to describe the algorithms supported by an authenticator.
//
// See: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialparameters
type PublicKeyCredentialParameters struct {
// Type is the credential type, typically "public-key".
Type string `json:"type"`
// Alg is the COSE algorithm identifier.
Alg webauthncose.COSEAlgorithmIdentifier `json:"alg"`
}
type AuthenticatorAttestationTypes []AuthenticatorAttestationType
func (t AuthenticatorAttestationTypes) HasBasicFull() bool {
for _, a := range t {
if a == BasicFull || a == AttCA {
return true
}
}
return false
}
// AuthenticatorAttestationType represents the attestation type supported by an authenticator. Each constant has a
// case-sensitive string representation used in the authoritative metadata for FIDO authenticators.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authenticator-attestation-types
type AuthenticatorAttestationType string
const (
// BasicFull - Indicates full basic attestation, based on an attestation private key shared among a class of authenticators (i.e. same model). Authenticators must provide its attestation signature during the registration process for the same reason. The attestation trust anchor is shared with FIDO Servers out of band (as part of the Metadata). This sharing process should be done according to [UAFMetadataService].
BasicFull AuthenticatorAttestationType = "basic_full"
// BasicSurrogate - Just syntactically a Basic Attestation. The attestation object self-signed, i.e. it is signed using the UAuth.priv key, i.e. the key corresponding to the UAuth.pub key included in the attestation object. As a consequence it does not provide a cryptographic proof of the security characteristics. But it is the best thing we can do if the authenticator is not able to have an attestation private key.
BasicSurrogate AuthenticatorAttestationType = "basic_surrogate"
// Ecdaa - Indicates use of elliptic curve based direct anonymous attestation as defined in [FIDOEcdaaAlgorithm]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
Ecdaa AuthenticatorAttestationType = "ecdaa"
// AttCA - Indicates PrivacyCA attestation as defined in [TCG-CMCProfile-AIKCertEnroll]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
AttCA AuthenticatorAttestationType = "attca"
// AnonCA In this case, the authenticator uses an Anonymization CA which dynamically generates per-credential attestation certificates such that the attestation statements presented to Relying Parties do not provide uniquely identifiable information, i.e., that might be used for tracking purposes. The applicable [WebAuthn] attestation formats "fmt" are Google SafetyNet Attestation "android-safetynet", Android Keystore Attestation "android-key", Apple Anonymous Attestation "apple", and Apple Application Attestation "apple-appattest".
AnonCA AuthenticatorAttestationType = "anonca"
// None - Indicates absence of attestation.
None AuthenticatorAttestationType = "none"
)
// KeyScope represents the scope of keys generated and maintained by an authenticator model.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type KeyScope string
const (
// KeyScopeNone is the zero value indicating the field is absent (defaults to PublicKeyCredentialSource).
KeyScopeNone KeyScope = ""
// PublicKeyCredentialSource indicates the authenticator only generates/maintains main FIDO credentials.
PublicKeyCredentialSource KeyScope = "public-key-credential-source" //nolint:gosec
// DeviceSupplementalPublicKeys indicates the authenticator only generates/maintains device-scoped supplemental
// public keys (SPK extension).
DeviceSupplementalPublicKeys KeyScope = "device-spk"
// ProviderSupplementalPublicKeys indicates the authenticator only generates/maintains provider-scoped supplemental
// public keys (SPK extension).
ProviderSupplementalPublicKeys KeyScope = "provider-spk"
)
// MultiDeviceCredentialSupport describes whether an authenticator supports multi-device credentials (passkeys).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type MultiDeviceCredentialSupport string
const (
// MultiDeviceCredentialUnsupported indicates all private keys are designed to stay within the authenticator
// boundary. This is the implicit default when the field is absent.
MultiDeviceCredentialUnsupported MultiDeviceCredentialSupport = "unsupported"
// MultiDeviceCredentialExplicit indicates the authenticator explicitly marks keys as multi-device or single-device
// via the Backup Eligibility flag.
MultiDeviceCredentialExplicit MultiDeviceCredentialSupport = "explicit"
// MultiDeviceCredentialImplicit indicates all private keys relating to Public Key Credential Source may be backed
// up.
MultiDeviceCredentialImplicit MultiDeviceCredentialSupport = "implicit"
)
// AuthenticatorStatus describes the status of an authenticator model as identified by its AAID/AAGUID and potentially
// some additional information (such as a specific attestation key).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-authnr-stat
type AuthenticatorStatus string
const (
// NotFidoCertified - This authenticator is not FIDO certified.
NotFidoCertified AuthenticatorStatus = "NOT_FIDO_CERTIFIED"
// FidoCertified - This authenticator has passed FIDO functional certification. This certification scheme is phased out and will be replaced by FIDO_CERTIFIED_L1.
FidoCertified AuthenticatorStatus = "FIDO_CERTIFIED"
// UserVerificationBypass - Indicates that malware is able to bypass the user verification. This means that the authenticator could be used without the user's consent and potentially even without the user's knowledge.
//nolint:gosec
UserVerificationBypass AuthenticatorStatus = "USER_VERIFICATION_BYPASS"
// AttestationKeyCompromise - Indicates that an attestation key for this authenticator is known to be compromised. Additional data should be supplied, including the key identifier and the date of compromise, if known.
AttestationKeyCompromise AuthenticatorStatus = "ATTESTATION_KEY_COMPROMISE"
// UserKeyRemoteCompromise - This authenticator has identified weaknesses that allow registered keys to be compromised and should not be trusted. This would include both, i.e. weak entropy that causes predictable keys to be generated or side channels that allow keys or signatures to be forged, guessed or extracted.
UserKeyRemoteCompromise AuthenticatorStatus = "USER_KEY_REMOTE_COMPROMISE"
// UserKeyPhysicalCompromise - This authenticator has known weaknesses in its key protection mechanism(s) that allow user keys to be extracted by an adversary in physical possession of the device.
UserKeyPhysicalCompromise AuthenticatorStatus = "USER_KEY_PHYSICAL_COMPROMISE"
// UpdateAvailable - A software or firmware update is available for the device. Additional data should be supplied including a URL where users can obtain an update and the date the update was published.
UpdateAvailable AuthenticatorStatus = "UPDATE_AVAILABLE"
// Retired - The authenticator vendor has decided to retire the product, and this authenticator should not be
// accepted any longer.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#dom-authenticatorstatus-retired
Retired AuthenticatorStatus = "RETIRED"
// Revoked - The FIDO Alliance has determined that this authenticator should not be trusted for any reason, for example if it is known to be a fraudulent product or contain a deliberate backdoor.
Revoked AuthenticatorStatus = "REVOKED"
// SelfAssertionSubmitted - The authenticator vendor has completed and submitted the self-certification checklist to the FIDO Alliance. If this completed checklist is publicly available, the URL will be specified in StatusReportJSON.url.
SelfAssertionSubmitted AuthenticatorStatus = "SELF_ASSERTION_SUBMITTED"
// FidoCertifiedL1 - The authenticator has passed FIDO Authenticator certification at level 1. This level is the more strict successor of FIDO_CERTIFIED.
FidoCertifiedL1 AuthenticatorStatus = "FIDO_CERTIFIED_L1"
// FidoCertifiedL1plus - The authenticator has passed FIDO Authenticator certification at level 1+. This level is the more than level 1.
FidoCertifiedL1plus AuthenticatorStatus = "FIDO_CERTIFIED_L1plus"
// FidoCertifiedL2 - The authenticator has passed FIDO Authenticator certification at level 2. This level is more strict than level 1+.
FidoCertifiedL2 AuthenticatorStatus = "FIDO_CERTIFIED_L2"
// FidoCertifiedL2plus - The authenticator has passed FIDO Authenticator certification at level 2+. This level is more strict than level 2.
FidoCertifiedL2plus AuthenticatorStatus = "FIDO_CERTIFIED_L2plus"
// FidoCertifiedL3 - The authenticator has passed FIDO Authenticator certification at level 3. This level is more strict than level 2+.
FidoCertifiedL3 AuthenticatorStatus = "FIDO_CERTIFIED_L3"
// FidoCertifiedL3plus - The authenticator has passed FIDO Authenticator certification at level 3+. This level is more strict than level 3.
FidoCertifiedL3plus AuthenticatorStatus = "FIDO_CERTIFIED_L3plus"
// FIPS140CertifiedL1 - The authenticator has passed FIPS 140 certification at overall level 1.
FIPS140CertifiedL1 AuthenticatorStatus = "FIPS140_CERTIFIED_L1"
// FIPS140CertifiedL2 - The authenticator has passed FIPS 140 certification at overall level 2.
FIPS140CertifiedL2 AuthenticatorStatus = "FIPS140_CERTIFIED_L2"
// FIPS140CertifiedL3 - The authenticator has passed FIPS 140 certification at overall level 3.
FIPS140CertifiedL3 AuthenticatorStatus = "FIPS140_CERTIFIED_L3"
// FIPS140CertifiedL4 - The authenticator has passed FIPS 140 certification at overall level 4.
FIPS140CertifiedL4 AuthenticatorStatus = "FIPS140_CERTIFIED_L4"
)
// defaultUndesiredAuthenticatorStatus is an array of undesirable authenticator statuses.
var defaultUndesiredAuthenticatorStatus = [...]AuthenticatorStatus{
AttestationKeyCompromise,
UserVerificationBypass,
UserKeyRemoteCompromise,
UserKeyPhysicalCompromise,
Retired,
Revoked,
}
// IsUndesiredAuthenticatorStatus returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatus(status AuthenticatorStatus) bool {
for _, s := range defaultUndesiredAuthenticatorStatus {
if s == status {
return true
}
}
return false
}
// IsUndesiredAuthenticatorStatusSlice returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatusSlice(status AuthenticatorStatus, values []AuthenticatorStatus) bool {
for _, s := range values {
if s == status {
return true
}
}
return false
}
// IsUndesiredAuthenticatorStatusMap returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatusMap(status AuthenticatorStatus, values map[AuthenticatorStatus]bool) bool {
_, ok := values[status]
return ok
}
// AuthenticationAlgorithm represents the authentication algorithm supported by an authenticator.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authentication-algorithms
type AuthenticationAlgorithm string
const (
// ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW is an ECDSA signature on the NIST secp256r1 curve which must have raw R and
// S buffers, encoded in big-endian order.
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_raw"
// ALG_SIGN_SECP256R1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the NIST secp256r1
// curve.
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_der"
// ALG_SIGN_RSASSA_PSS_SHA256_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA256_RAW AuthenticationAlgorithm = "rsassa_pss_sha256_raw"
// ALG_SIGN_RSASSA_PSS_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
// RSASSA-PSS RFC3447 signature RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA256_DER AuthenticationAlgorithm = "rsassa_pss_sha256_der"
// ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW is an ECDSA signature on the secp256k1 curve which must have raw R and S
// buffers, encoded in big-endian order.
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_raw"
// ALG_SIGN_SECP256K1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the secp256k1 curve.
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_der"
// ALG_SIGN_SM2_SM3_RAW is a Chinese SM2 elliptic curve based signature algorithm combined with SM3 hash algorithm
// OSCCA-SM2 OSCCA-SM3.
ALG_SIGN_SM2_SM3_RAW AuthenticationAlgorithm = "sm2_sm3_raw"
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW is the EMSA-PKCS1-v1_5 signature as defined in RFC3447.
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_raw"
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
// EMSA-PKCS1-v1_5 signature as defined in RFC3447.
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_der"
// ALG_SIGN_RSASSA_PSS_SHA384_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA384_RAW AuthenticationAlgorithm = "rsassa_pss_sha384_raw"
// ALG_SIGN_RSASSA_PSS_SHA512_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA512_RAW AuthenticationAlgorithm = "rsassa_pss_sha512_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA256(aka RS256) signature must have raw
// S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha256_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA384(aka RS384) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha384_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA512(aka RS512) signature must have raw
// S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha512_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA1(aka RS1) signature must have raw S
// buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha1_raw"
// ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW is an ECDSA signature on the NIST secp384r1 curve with SHA384(aka: ES384)
// which must have raw R and S buffers, encoded in big-endian order.
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW AuthenticationAlgorithm = "secp384r1_ecdsa_sha384_raw"
// ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW is an ECDSA signature on the NIST secp512r1 curve with SHA512(aka: ES512)
// which must have raw R and S buffers, encoded in big-endian order.
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW AuthenticationAlgorithm = "secp521r1_ecdsa_sha512_raw"
// ALG_SIGN_ED25519_EDDSA_SHA512_RAW is an EdDSA signature on the curve 25519, which must have raw R and S buffers,
// encoded in big-endian order.
ALG_SIGN_ED25519_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed25519_eddsa_sha512_raw"
// ALG_SIGN_ED448_EDDSA_SHA512_RAW is an EdDSA signature on the curve Ed448, which must have raw R and S buffers,
// encoded in big-endian order.
ALG_SIGN_ED448_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed448_eddsa_sha512_raw"
)
// TODO: this goes away after webauthncose.CredentialPublicKey gets implemented.
type algKeyCose struct {
KeyType webauthncose.COSEKeyType
Algorithm webauthncose.COSEAlgorithmIdentifier
Curve webauthncose.COSEEllipticCurve
}
func algKeyCoseDictionary() func(AuthenticationAlgorithm) algKeyCose {
mapping := map[AuthenticationAlgorithm]algKeyCose{
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
ALG_SIGN_RSASSA_PSS_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
ALG_SIGN_RSASSA_PSS_SHA256_DER: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
ALG_SIGN_RSASSA_PSS_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS384},
ALG_SIGN_RSASSA_PSS_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS512},
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS256},
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS384},
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS512},
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS1},
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES384, Curve: webauthncose.P384},
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES512, Curve: webauthncose.P521},
ALG_SIGN_ED25519_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed25519},
ALG_SIGN_ED448_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed448},
}
return func(key AuthenticationAlgorithm) algKeyCose {
return mapping[key]
}
}
func AlgKeyMatch(key algKeyCose, algs []AuthenticationAlgorithm) bool {
for _, alg := range algs {
if reflect.DeepEqual(algKeyCoseDictionary()(alg), key) {
return true
}
}
return false
}
// PublicKeyAlgAndEncoding represents the public key format supported by an authenticator during registration.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#public-key-representation-formats
type PublicKeyAlgAndEncoding string
const (
// ALG_KEY_ECC_X962_RAW is a raw ANSI X9.62 formatted Elliptic Curve public key.
ALG_KEY_ECC_X962_RAW PublicKeyAlgAndEncoding = "ecc_x962_raw"
// ALG_KEY_ECC_X962_DER is a DER ITU-X690-2008 encoded ANSI X.9.62 formatted SubjectPublicKeyInfo RFC5480 specifying an elliptic curve public key.
ALG_KEY_ECC_X962_DER PublicKeyAlgAndEncoding = "ecc_x962_der"
// ALG_KEY_RSA_2048_RAW is a raw encoded 2048-bit RSA public key RFC3447.
ALG_KEY_RSA_2048_RAW PublicKeyAlgAndEncoding = "rsa_2048_raw"
// ALG_KEY_RSA_2048_DER is a ASN.1 DER [ITU-X690-2008] encoded 2048-bit RSA RFC3447 public key RFC4055.
ALG_KEY_RSA_2048_DER PublicKeyAlgAndEncoding = "rsa_2048_der"
// ALG_KEY_COSE is a COSE_Key format, as defined in Section 7 of RFC8152. This encoding includes its own field for indicating the public key algorithm.
ALG_KEY_COSE PublicKeyAlgAndEncoding = "cose"
)
type Error struct {
// Short name for the type of error that has occurred.
Type string `json:"type"`
// Additional details about the error.
Details string `json:"error"`
// Information to help debug the error.
DevInfo string `json:"debug"`
}
func (e *Error) Error() string {
return e.Details
}
// Clock is an interface used to implement clock functionality in various metadata areas.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
// RealClock is just a real clock.
type RealClock struct{}
// Now returns the current time.
func (RealClock) Now() time.Time {
return time.Now()
}
@@ -0,0 +1,205 @@
package protocol
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// The CredentialAssertionResponse is the raw response returned to the Relying Party from an authenticator when we request a
// credential for login/assertion.
type CredentialAssertionResponse struct {
PublicKeyCredential
AssertionResponse AuthenticatorAssertionResponse `json:"response"`
}
// The ParsedCredentialAssertionData is the parsed [CredentialAssertionResponse] that has been marshalled into a format
// that allows us to verify the client and authenticator data inside the response.
type ParsedCredentialAssertionData struct {
ParsedPublicKeyCredential
Response ParsedAssertionResponse
Raw CredentialAssertionResponse
}
// The AuthenticatorAssertionResponse contains the raw authenticator assertion data and is parsed into
// [ParsedAssertionResponse].
type AuthenticatorAssertionResponse struct {
AuthenticatorResponse
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
Signature URLEncodedBase64 `json:"signature"`
UserHandle URLEncodedBase64 `json:"userHandle,omitempty"`
}
// ParsedAssertionResponse is the parsed form of [AuthenticatorAssertionResponse].
type ParsedAssertionResponse struct {
CollectedClientData CollectedClientData
AuthenticatorData AuthenticatorData
Signature []byte
UserHandle []byte
}
// ParseCredentialRequestResponse parses a login/assertion response from a [*http.Request]. The request body is
// automatically drained and closed after parsing.
//
// This is the standard entry point when using [net/http]. For implementations that don't use [net/http], see
// [ParseCredentialRequestResponseBody] (accepts an [io.Reader]) or [ParseCredentialRequestResponseBytes] (accepts a
// []byte).
func ParseCredentialRequestResponse(response *http.Request) (*ParsedCredentialAssertionData, error) {
if response == nil || response.Body == nil {
return nil, ErrBadRequest.WithDetails("No response given")
}
defer func(request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
_ = request.Body.Close()
}(response)
return ParseCredentialRequestResponseBody(response.Body)
}
// ParseCredentialRequestResponseBody parses a login/assertion response from an [io.Reader]. The caller is responsible
// for closing the reader if applicable.
//
// This is the framework-agnostic variant of [ParseCredentialRequestResponse]. For a [*http.Request] use
// [ParseCredentialRequestResponse] instead. For raw bytes use [ParseCredentialRequestResponseBytes].
func ParseCredentialRequestResponseBody(body io.Reader) (par *ParsedCredentialAssertionData, err error) {
var car CredentialAssertionResponse
if err = decodeBody(body, &car); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
}
return car.Parse()
}
// ParseCredentialRequestResponseBytes parses a login/assertion response from raw bytes.
//
// See also [ParseCredentialRequestResponse] (for [*http.Request]) and [ParseCredentialRequestResponseBody] (for
// [io.Reader]).
func ParseCredentialRequestResponseBytes(data []byte) (par *ParsedCredentialAssertionData, err error) {
var car CredentialAssertionResponse
if err = decodeBytes(data, &car); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
}
return car.Parse()
}
// Parse validates and parses the [CredentialAssertionResponse] into a [ParsedCredentialAssertionData]. Most
// implementations should use [ParseCredentialRequestResponse], [ParseCredentialRequestResponseBody], or
// [ParseCredentialRequestResponseBytes] instead of calling this method directly.
func (car CredentialAssertionResponse) Parse() (par *ParsedCredentialAssertionData, err error) {
if car.ID == "" {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID missing")
}
if _, err = base64.RawURLEncoding.DecodeString(car.ID); err != nil {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID not base64url encoded").WithError(err)
}
if car.Type != string(PublicKeyCredentialType) {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with bad type")
}
var attachment AuthenticatorAttachment
switch att := AuthenticatorAttachment(car.AuthenticatorAttachment); att {
case Platform, CrossPlatform:
attachment = att
}
par = &ParsedCredentialAssertionData{
ParsedPublicKeyCredential{
ParsedCredential{car.ID, car.Type}, car.RawID, car.ClientExtensionResults, attachment,
},
ParsedAssertionResponse{
Signature: car.AssertionResponse.Signature,
UserHandle: car.AssertionResponse.UserHandle,
},
car,
}
// Step 5. Let JSONtext be the result of running UTF-8 decode on the value of cData.
// We don't call it cData but this is Step 5 in the spec.
if err = json.Unmarshal(car.AssertionResponse.ClientDataJSON, &par.Response.CollectedClientData); err != nil {
return nil, err
}
if err = par.Response.AuthenticatorData.Unmarshal(car.AssertionResponse.AuthenticatorData); err != nil {
return nil, ErrParsingData.WithDetails("Error unmarshalling auth data").WithError(err)
}
return par, nil
}
// Verify the remaining elements of the assertion data by following the steps outlined in the referenced specification
// documentation. It's important to note that the credentialBytes field is the CBOR representation of the credential.
//
// Specification: §7.2 Verifying an Authentication Assertion (https://www.w3.org/TR/webauthn/#sctn-verifying-assertion)
func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID, appID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin, verifyUser, verifyUserPresence bool, credentialBytes []byte) error {
// Steps 4 through 6 in verifying the assertion data (https://www.w3.org/TR/webauthn/#verifying-assertion) are
// "assertive" steps, i.e. "Let JSONtext be the result of running UTF-8 decode on the value of cData."
// We handle these steps in part as we verify but also beforehand
//
// Handle steps 7 through 10 of assertion by verifying stored data against the Collected Client Data
// returned by the authenticator.
validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify, allowCrossOrigin)
if validError != nil {
return validError
}
// Begin Step 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
var appIDHash [32]byte
if appID != "" {
appIDHash = sha256.Sum256([]byte(appID))
}
// Handle steps 11 through 14, verifying the authenticator data.
validError = p.Response.AuthenticatorData.Verify(rpIDHash[:], appIDHash[:], verifyUser, verifyUserPresence)
if validError != nil {
return validError
}
// Step 15. Let hash be the result of computing a hash over the cData using SHA-256.
clientDataHash := sha256.Sum256(p.Raw.AssertionResponse.ClientDataJSON)
// Step 16. Using the credential public key looked up in step 3, verify that sig is
// a valid signature over the binary concatenation of authData and hash.
sigData := append(p.Raw.AssertionResponse.AuthenticatorData, clientDataHash[:]...) //nolint:gocritic // This is intentional.
var (
key any
err error
)
// If the Session Data does not contain the appID extension or it wasn't reported as used by the Client/RP then we
// use the standard CTAP2 public key parser.
if appID == "" {
key, err = webauthncose.ParsePublicKey(credentialBytes)
} else {
key, err = webauthncose.ParseFIDOPublicKey(credentialBytes)
}
if err != nil {
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error parsing the assertion public key: %+v", err)).WithError(err)
}
valid, err := webauthncose.VerifySignature(key, sigData, p.Response.Signature)
if !valid || err != nil {
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error validating the assertion signature: %+v", err)).WithError(err)
}
return nil
}
@@ -0,0 +1,253 @@
package protocol
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// AuthenticatorAttestationResponse is the initial unpacked 'response' object received by the relying party. This
// contains the clientDataJSON object, which will be marshalled into [CollectedClientData], and the 'attestationObject',
// which contains information about the authenticator, and the newly minted public key credential. The information in
// both objects are used to verify the authenticity of the ceremony and new credential.
//
// See: https://www.w3.org/TR/webauthn/#typedefdef-publickeycredentialjson
type AuthenticatorAttestationResponse struct {
// The byte slice of clientDataJSON, which becomes CollectedClientData.
AuthenticatorResponse
Transports []string `json:"transports,omitempty"`
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
PublicKey URLEncodedBase64 `json:"publicKey"`
PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm"`
// AttestationObject is the byte slice version of attestationObject.
// This attribute contains an attestation object, which is opaque to, and
// cryptographically protected against tampering by, the client. The
// attestation object contains both authenticator data and an attestation
// statement. The former contains the AAGUID, a unique credential ID, and
// the credential public key. The contents of the attestation statement are
// determined by the attestation statement format used by the authenticator.
// It also contains any additional information that the Relying Party's server
// requires to validate the attestation statement, as well as to decode and
// validate the authenticator data along with the JSON-serialized client data.
AttestationObject URLEncodedBase64 `json:"attestationObject"`
}
// ParsedAttestationResponse is the parsed version of [AuthenticatorAttestationResponse].
type ParsedAttestationResponse struct {
CollectedClientData CollectedClientData
AttestationObject AttestationObject
Transports []AuthenticatorTransport
}
// AttestationObject is the raw attestationObject.
//
// Authenticators SHOULD also provide some form of attestation, if possible. If an authenticator does, the basic
// requirement is that the authenticator can produce, for each credential public key, an attestation statement
// verifiable by the WebAuthn Relying Party. Typically, this attestation statement contains a signature by an
// attestation private key over the attested credential public key and a challenge, as well as a certificate or similar
// data providing provenance information for the attestation public key, enabling the Relying Party to make a trust
// decision. However, if an attestation key pair is not available, then the authenticator MAY either perform self
// attestation of the credential public key with the corresponding credential private key, or otherwise perform no
// attestation. All this information is returned by authenticators any time a new public key credential is generated, in
// the overall form of an attestation object.
//
// Specification: §6.5. Attestation (https://www.w3.org/TR/webauthn/#sctn-attestation)
type AttestationObject struct {
// The authenticator data, including the newly created public key. See [AuthenticatorData] for more info.
AuthData AuthenticatorData
// The byteform version of the authenticator data, used in part for signature validation.
RawAuthData []byte `json:"authData"`
// The format of the Attestation data.
Format string `json:"fmt"`
// The attestation statement data sent back if attestation is requested.
AttStatement map[string]any `json:"attStmt,omitempty"`
// Type is the attestation type as conveyed by the authenticator, one of the values defined by
// [metadata.AuthenticatorAttestationType] (i.e. "basic_full", "basic_surrogate", "attca", "anonca", "none").
// It is populated as a side-effect of a successful [AttestationObject.VerifyAttestation]; before that the field
// is empty. This field is excluded from serialization because the attestation object wire format does not carry
// this value; it is derived by the format-specific verifier.
Type string `json:"-"`
}
// NonCompoundAttestationObject is a subset of [AttestationObject] used within compound attestation statements. Each
// sub-statement in a compound attestation has its own format and attestation statement but shares authenticator data
// with the parent.
//
// Specification: §8.9. Compound Attestation Statement Format (https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation)
type NonCompoundAttestationObject struct {
// The format of the Attestation data.
Format string `json:"fmt"`
// The attestation statement data sent back if attestation is requested.
AttStatement map[string]any `json:"attStmt,omitempty"`
}
type attestationFormatValidationHandler func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error)
var attestationRegistry = make(map[AttestationFormat]attestationFormatValidationHandler)
// RegisterAttestationFormat is a method to register attestation formats with the library. Generally using one of the
// locally registered attestation formats is enough.
func RegisterAttestationFormat(format AttestationFormat, handler attestationFormatValidationHandler) {
attestationRegistry[format] = handler
}
// Parse the values returned in the authenticator response and perform attestation verification
// Step 8. This returns a fully decoded struct with the data put into a format that can be
// used to verify the user and credential that was created.
func (ccr *AuthenticatorAttestationResponse) Parse() (p *ParsedAttestationResponse, err error) {
p = &ParsedAttestationResponse{}
if err = json.Unmarshal(ccr.ClientDataJSON, &p.CollectedClientData); err != nil {
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
}
if err = webauthncbor.Unmarshal(ccr.AttestationObject, &p.AttestationObject); err != nil {
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
}
// Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
// structure to obtain the attestation statement format fmt, the authenticator data authData, and
// the attestation statement attStmt.
if err = p.AttestationObject.AuthData.Unmarshal(p.AttestationObject.RawAuthData); err != nil {
return nil, err
}
if !p.AttestationObject.AuthData.Flags.HasAttestedCredentialData() {
return nil, ErrAttestationFormat.WithInfo("Attestation missing attested credential data flag")
}
for _, t := range ccr.Transports {
if transport, ok := internalRemappedAuthenticatorTransport[t]; ok {
p.Transports = append(p.Transports, transport)
} else {
p.Transports = append(p.Transports, AuthenticatorTransport(t))
}
}
return p, nil
}
// Verify performs Steps 13 through 19 of registration verification.
//
// Steps 13 through 15 are verified against the auth data. These steps are identical to 15 through 18 for assertion so we
// handle them with AuthData.
func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, userPresenceRequired bool, mds metadata.Provider, credParams []CredentialParameter) (err error) {
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
// Begin Step 13 through 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
if err = a.AuthData.Verify(rpIDHash[:], nil, userVerificationRequired, userPresenceRequired); err != nil {
return err
}
// Step 16. Verify that the "alg" parameter in the credential public key in
// authData matches the alg attribute of one of the items in options.pubKeyCredParams.
var pk webauthncose.PublicKeyData
if err = webauthncbor.Unmarshal(a.AuthData.AttData.CredentialPublicKey, &pk); err != nil {
return err
}
found := false
for _, credParam := range credParams {
if int(pk.Algorithm) == int(credParam.Algorithm) {
found = true
break
}
}
if !found {
return ErrAttestationFormat.WithInfo("Credential public key algorithm not supported")
}
return a.VerifyAttestation(clientDataHash, mds)
}
// VerifyAttestation only verifies the attestation object excluding the AuthData values. If you wish to also verify the
// AuthData values you should use [Verify].
func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider) (err error) {
// Step 18. Determine the attestation statement format by performing a
// USASCII case-sensitive match on fmt against the set of supported
// WebAuthn Attestation Statement Format Identifier values. The up-to-date
// list of registered WebAuthn Attestation Statement Format Identifier
// values is maintained in the IANA registry of the same name
// [WebAuthn-Registries] (https://www.w3.org/TR/webauthn/#biblio-webauthn-registries).
//
// Since there is not an active registry yet, we'll check it against our internal
// Supported types.
//
// But first let's make sure attestation is present. If it isn't, we don't need to handle
// any of the following steps.
if AttestationFormat(a.Format) == AttestationFormatNone {
if len(a.AttStatement) != 0 {
return ErrAttestationFormat.WithInfo("Attestation format none with attestation present")
}
a.Type = string(metadata.None)
return nil
}
var (
handler attestationFormatValidationHandler
valid bool
)
if handler, valid = attestationRegistry[AttestationFormat(a.Format)]; !valid {
return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", a.Format))
}
var (
aaguid uuid.UUID
attestationType string
x5cs []any
)
// Step 19. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using
// the attestation statement format fmt’s verification procedure given attStmt, authData and the hash of the serialized
// client data computed in step 7.
if attestationType, x5cs, err = handler(*a, clientDataHash, mds); err != nil {
var e *Error
if errors.As(err, &e) {
return e.WithInfo(attestationType)
}
return ErrInvalidAttestation.WithDetails(err.Error()).WithInfo(attestationType).WithError(err)
}
a.Type = attestationType
if len(a.AuthData.AttData.AAGUID) != 0 {
if aaguid, err = uuid.FromBytes(a.AuthData.AttData.AAGUID); err != nil {
return ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
}
}
if mds == nil {
return nil
}
if e := ValidateMetadata(context.Background(), mds, aaguid, a.Type, a.Format, x5cs); e != nil {
return ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
}
return nil
}
@@ -0,0 +1,262 @@
package protocol
import (
"bytes"
"crypto/x509"
"encoding/asn1"
"fmt"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerAndroidKey is the handler for the Android Key Attestation Statement Format.
//
// An Android key attestation statement consists simply of the Android attestation statement, which is a series of DER
// encoded X.509 certificates. See the Android developer documentation. Its syntax is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "android-key",
// attStmt: androidStmtFormat
// )
//
// androidStmtFormat = {
// alg: COSEAlgorithmIdentifier,
// sig: bytes,
// x5c: [ credCert: bytes, * (caCert: bytes) ]
// }
//
// Specification: §8.4. Android Key Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-android-key-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
alg int64
sig []byte
ok bool
)
// Given the verification procedure inputs attStmt, authenticatorData and clientDataHash, the verification procedure is as follows:
// §8.4.1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
// the contained fields.
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
// used to generate the attestation signature.
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
// Get the sig value - A byte string containing the attestation signature.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
// §8.4.2. Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash
// using the public key in the first certificate in x5c with the algorithm specified in alg.
var (
x5c []any
certs []*x509.Certificate
)
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
return "", nil, err
}
if len(certs) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
}
credCert := certs[0]
if _, err = attStatementCertChainVerify(certs, attAndroidKeyHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
}
signatureData := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
} else if err = credCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// Verify that the public key in the first certificate in x5c matches the credentialPublicKey in the attestedCredentialData in authenticatorData.
var attPublicKeyData webauthncose.EC2PublicKeyData
if attPublicKeyData, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
return "", nil, err
}
var valid bool
if valid, err = attPublicKeyData.Verify(signatureData, sig); err != nil || !valid {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error parsing public key: %+v", err)).WithError(err)
}
// §8.4.3. Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
// attCert.Extensions.
// As noted in §8.4.1 (https://www.w3.org/TR/webauthn/#key-attstn-cert-requirements) the Android Key Attestation
// certificate's android key attestation certificate extension data is identified by the OID
// "1.3.6.1.4.1.11129.2.1.17".
var attExtBytes []byte
for _, ext := range credCert.Extensions {
if ext.Id.Equal(oidExtensionAndroidKeystore) {
attExtBytes = ext.Value
}
}
if len(attExtBytes) == 0 {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.3.6.1.4.1.11129.2.1.17")
}
decoded := keyDescription{}
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse Android key attestation certificate extensions").WithError(err)
}
// Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
if !bytes.Equal(decoded.AttestationChallenge, clientDataHash) {
return "", nil, ErrAttestationFormat.WithDetails("Attestation challenge not equal to clientDataHash")
}
// The AuthorizationList.allApplications field is not present on either authorization list (softwareEnforced nor teeEnforced), since PublicKeyCredential MUST be scoped to the RP ID.
if decoded.SoftwareEnforced.AllApplications != nil || decoded.TeeEnforced.AllApplications != nil {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains all applications field")
}
// For the following, use only the teeEnforced authorization list if the RP wants to accept only keys from a trusted execution environment, otherwise use the union of teeEnforced and softwareEnforced.
// The value in the AuthorizationList.origin field is equal to KM_ORIGIN_GENERATED (which == 0).
if decoded.SoftwareEnforced.Origin != KM_ORIGIN_GENERATED || decoded.TeeEnforced.Origin != KM_ORIGIN_GENERATED {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with origin not equal KM_ORIGIN_GENERATED")
}
// The value in the AuthorizationList.purpose field is equal to KM_PURPOSE_SIGN (which == 2).
if !contains(decoded.SoftwareEnforced.Purpose, KM_PURPOSE_SIGN) && !contains(decoded.TeeEnforced.Purpose, KM_PURPOSE_SIGN) {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with purpose not equal KM_PURPOSE_SIGN")
}
return string(metadata.BasicFull), x5c, err
}
func contains(s []int, e int) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
type keyDescription struct {
AttestationVersion int
AttestationSecurityLevel asn1.Enumerated
KeymasterVersion int
KeymasterSecurityLevel asn1.Enumerated
AttestationChallenge []byte
UniqueID []byte
SoftwareEnforced authorizationList
TeeEnforced authorizationList
}
type authorizationList struct {
Purpose []int `asn1:"tag:1,explicit,set,optional"`
Algorithm int `asn1:"tag:2,explicit,optional"`
KeySize int `asn1:"tag:3,explicit,optional"`
Digest []int `asn1:"tag:5,explicit,set,optional"`
Padding []int `asn1:"tag:6,explicit,set,optional"`
EcCurve int `asn1:"tag:10,explicit,optional"`
RsaPublicExponent int `asn1:"tag:200,explicit,optional"`
RollbackResistance any `asn1:"tag:303,explicit,optional"`
ActiveDateTime int `asn1:"tag:400,explicit,optional"`
OriginationExpireDateTime int `asn1:"tag:401,explicit,optional"`
UsageExpireDateTime int `asn1:"tag:402,explicit,optional"`
NoAuthRequired any `asn1:"tag:503,explicit,optional"`
UserAuthType int `asn1:"tag:504,explicit,optional"`
AuthTimeout int `asn1:"tag:505,explicit,optional"`
AllowWhileOnBody any `asn1:"tag:506,explicit,optional"`
TrustedUserPresenceRequired any `asn1:"tag:507,explicit,optional"`
TrustedConfirmationRequired any `asn1:"tag:508,explicit,optional"`
UnlockedDeviceRequired any `asn1:"tag:509,explicit,optional"`
AllApplications any `asn1:"tag:600,explicit,optional"`
ApplicationID any `asn1:"tag:601,explicit,optional"`
CreationDateTime int `asn1:"tag:701,explicit,optional"`
Origin int `asn1:"tag:702,explicit,optional"`
RootOfTrust rootOfTrust `asn1:"tag:704,explicit,optional"`
OsVersion int `asn1:"tag:705,explicit,optional"`
OsPatchLevel int `asn1:"tag:706,explicit,optional"`
AttestationApplicationID []byte `asn1:"tag:709,explicit,optional"`
AttestationIDBrand []byte `asn1:"tag:710,explicit,optional"`
AttestationIDDevice []byte `asn1:"tag:711,explicit,optional"`
AttestationIDProduct []byte `asn1:"tag:712,explicit,optional"`
AttestationIDSerial []byte `asn1:"tag:713,explicit,optional"`
AttestationIDImei []byte `asn1:"tag:714,explicit,optional"`
AttestationIDMeid []byte `asn1:"tag:715,explicit,optional"`
AttestationIDManufacturer []byte `asn1:"tag:716,explicit,optional"`
AttestationIDModel []byte `asn1:"tag:717,explicit,optional"`
VendorPatchLevel int `asn1:"tag:718,explicit,optional"`
BootPatchLevel int `asn1:"tag:719,explicit,optional"`
}
type rootOfTrust struct {
verifiedBootKey []byte //nolint:unused
deviceLocked bool //nolint:unused
verifiedBootState verifiedBootState //nolint:unused
verifiedBootHash []byte //nolint:unused
}
type verifiedBootState int
const (
Verified verifiedBootState = iota
SelfSigned
Unverified
Failed
)
const (
// KM_ORIGIN_GENERATED means generated in keymaster. Should not exist outside the TEE.
KM_ORIGIN_GENERATED = iota
// KM_ORIGIN_DERIVED means derived inside keymaster. Likely exists off-device.
KM_ORIGIN_DERIVED
// KM_ORIGIN_IMPORTED means imported into keymaster. Existed as clear text in Android.
KM_ORIGIN_IMPORTED
// KM_ORIGIN_UNKNOWN means keymaster did not record origin. This value can only be seen on keys in a keymaster0
// implementation. The keymaster0 adapter uses this value to document the fact that it is unknown whether the key
// was generated inside or imported into keymaster.
KM_ORIGIN_UNKNOWN
)
const (
// KM_PURPOSE_ENCRYPT is usable with RSA, EC and AES keys.
KM_PURPOSE_ENCRYPT = iota
// KM_PURPOSE_DECRYPT is usable with RSA, EC and AES keys.
KM_PURPOSE_DECRYPT
// KM_PURPOSE_SIGN is usable with RSA, EC and HMAC keys.
KM_PURPOSE_SIGN
// KM_PURPOSE_VERIFY is usable with RSA, EC and HMAC keys.
KM_PURPOSE_VERIFY
// KM_PURPOSE_DERIVE_KEY is usable with EC keys.
KM_PURPOSE_DERIVE_KEY
// KM_PURPOSE_WRAP is usable with wrapped keys.
KM_PURPOSE_WRAP
)
var (
attAndroidKeyHardwareRootsCertPool *x509.CertPool
)
func init() {
RegisterAttestationFormat(AttestationFormatAndroidKey, attestationFormatValidationHandlerAndroidKey)
}
@@ -0,0 +1,105 @@
package protocol
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// attestationFormatValidationHandlerAppleAnonymous is the handler for the Apple Anonymous Attestation Statement Format.
//
// The syntax of an Apple attestation statement is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "apple",
// attStmt: appleStmtFormat
// )
//
// appleStmtFormat = {
// x5c: [ credCert: bytes, * (caCert: bytes) ]
// }
//
// Specification: §8.8. Apple Anonymous Attestation Statement Format
//
// See : https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation
func attestationFormatValidationHandlerAppleAnonymous(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
// to extract the contained fields.
var (
x5c []any
certs []*x509.Certificate
)
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
return "", nil, err
}
if len(certs) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
}
credCert := certs[0]
if _, err = attStatementCertChainVerify(certs, attAppleHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
}
// Step 2. Concatenate authenticatorData and clientDataHash to form nonceToHash.
nonceToHash := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
// Step 3. Perform SHA-256 hash of nonceToHash to produce nonce.
nonce := sha256.Sum256(nonceToHash)
// Step 4. Verify that nonce equals the value of the extension with OID 1.2.840.113635.100.8.2 in credCert.
var attExtBytes []byte
for _, ext := range credCert.Extensions {
if ext.Id.Equal(oidExtensionAppleAnonymousAttestation) {
attExtBytes = ext.Value
break
}
}
if len(attExtBytes) == 0 {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.2.840.113635.100.8.2")
}
decoded := AppleAnonymousAttestation{}
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse apple attestation certificate extensions").WithError(err)
}
if !bytes.Equal(decoded.Nonce, nonce[:]) {
return "", nil, ErrInvalidAttestation.WithDetails("Attestation certificate does not contain expected nonce")
}
// Step 5. Verify that the credential public key equals the Subject Public Key of credCert.
if _, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
return "", nil, err
}
// Step 6. If successful, return implementation-specific values representing attestation type Anonymization CA and
// attestation trust path x5c.
return string(metadata.AnonCA), x5c, nil
}
// AppleAnonymousAttestation represents the attestation format for Apple, who have not yet published a schema for the
// extension (as of JULY 2021.)
type AppleAnonymousAttestation struct {
Nonce []byte `asn1:"tag:1,explicit"`
}
var (
attAppleHardwareRootsCertPool *x509.CertPool
)
func init() {
RegisterAttestationFormat(AttestationFormatApple, attestationFormatValidationHandlerAppleAnonymous)
}
@@ -0,0 +1,117 @@
package protocol
import (
"context"
"fmt"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
func init() {
RegisterAttestationFormat(AttestationFormatCompound, attestationFormatValidationHandlerCompound)
}
// attestationFormatValidationHandlerCompound is the handler for the Compound Attestation Statement Format.
//
// The syntax of a Compound Attestation statement is defined by the following CDDL:
//
// $$attStmtType //= (
//
// fmt: "compound",
// attStmt: [2* nonCompoundAttStmt]
// )
//
// nonCompoundAttStmt = { $$attStmtType } .within { fmt: text .ne "compound", * any => any }
//
// Specification: §8.9. Compound Attestation Statement Forma
//
// See: https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerCompound(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
aaguid uuid.UUID
raw any
ok bool
stmts []any
subStmt map[string]any
attStmts []NonCompoundAttestationObject
)
if len(att.AuthData.AttData.AAGUID) != 0 {
if aaguid, err = uuid.FromBytes(att.AuthData.AttData.AAGUID); err != nil {
return "", nil, ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
}
}
if raw, ok = att.AttStatement[stmtAttStmt]; !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement missing attStmt")
}
if stmts, ok = raw.([]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array")
}
if len(stmts) < 2 {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array with at least two other statements")
}
for _, stmt := range stmts {
if subStmt, ok = stmt.(map[string]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt contains one or more items that isn't an object")
}
var attStmt NonCompoundAttestationObject
if attStmt.Format, ok = subStmt[stmtFmt].(string); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have a format")
}
if attStmt.AttStatement, ok = subStmt[stmtAttStmt].(map[string]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have an attestation statement")
}
switch AttestationFormat(attStmt.Format) {
case AttestationFormatCompound:
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has a format of compound which is not allowed")
case "":
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has an empty format which is not allowed")
default:
if _, ok = attestationRegistry[AttestationFormat(attStmt.Format)]; !ok {
return "", nil, ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation sub-statement format %s is unsupported", attStmt.Format))
}
attStmts = append(attStmts, attStmt)
}
}
for _, attStmt := range attStmts {
object := AttestationObject{
Format: attStmt.Format,
AttStatement: attStmt.AttStatement,
AuthData: att.AuthData,
RawAuthData: att.RawAuthData,
}
var (
cx5cs []any
subAttType string
)
if subAttType, cx5cs, err = attestationRegistry[AttestationFormat(object.Format)](object, clientDataHash, mds); err != nil {
return "", nil, err
}
if mds == nil {
continue
}
if e := ValidateMetadata(context.Background(), mds, aaguid, subAttType, object.Format, cx5cs); e != nil {
return "", nil, ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
}
}
return stmtTypNone, nil, nil
}
@@ -0,0 +1,155 @@
package protocol
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"fmt"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerFIDOU2F is the handler for the FIDO U2F Attestation Statement Format.
//
// The syntax of a FIDO U2F attestation statement is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "fido-u2f",
// attStmt: u2fStmtFormat
// )
//
// u2fStmtFormat = {
// x5c: [ attestnCert: bytes ],
// sig: bytes
// }
//
// Specification: §8.6. FIDO U2F Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation
func attestationFormatValidationHandlerFIDOU2F(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Signing procedure. Non-normative verification procedure of expected requirement.
// If the credential public key of the attested credential is not of algorithm -7 ("ES256"), stop and return an error.
var key webauthncose.EC2PublicKeyData
if err = webauthncbor.Unmarshal(att.AuthData.AttData.CredentialPublicKey, &key); err != nil {
return "", nil, ErrAttestationCertificate.WithDetails("Error parsing public key").WithError(err)
}
if webauthncose.COSEAlgorithmIdentifier(key.Algorithm) != webauthncose.AlgES256 {
return "", nil, ErrUnsupportedAlgorithm.WithDetails("Non-ES256 Public Key algorithm used")
}
var (
sig []byte
raw []byte
x5c []any
ok bool
)
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
// to extract the contained fields.
// Check for "x5c" which is a single element array containing the attestation certificate in X.509 format.
if x5c, ok = att.AttStatement[stmtX5C].([]any); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Missing properly formatted x5c data")
}
// Note: Packed Attestation, FIDO U2F Attestation, and Assertion Signatures require ASN.1 DER sig values, but it is
// RECOMMENDED that any new attestation formats defined not use ASN.1 encodings, but instead represent signatures as
// equivalent fixed-length byte arrays without internal structure, using the same representations as used by COSE
// signatures as defined in [RFC9053](https://www.rfc-editor.org/rfc/rfc9053.html) and
// [RFC8230](https://www.rfc-editor.org/rfc/rfc8230.html).
// This is described in §6.5.5 https://www.w3.org/TR/webauthn-3/#sctn-signature-attestation-types.
// Check for "sig" which is The attestation signature. The signature was calculated over the (raw) U2F
// registration response message https://www.w3.org/TR/webauthn/#biblio-fido-u2f-message-formats]
// received by the client from the authenticator.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Missing sig data")
}
// Step 2.
// 1. Check that x5c has exactly one element and let attCert be that element.
// 2. Let certificate public key be the public key conveyed by attCert.
// 3. If certificate public key is not an Elliptic Curve (EC) public key over the P-256 curve, terminate this
// algorithm and return an appropriate error.
// Step 2.1.
if len(x5c) != 1 {
return "", nil, ErrAttestationFormat.WithDetails("x5c must contain exactly one element")
}
// Step 2.2.
if raw, ok = x5c[0].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error decoding ASN.1 data from x5c")
}
attCert, err := x509.ParseCertificate(raw)
if err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1 data into certificate").WithError(err)
}
// Step 2.3.
if attCert.PublicKeyAlgorithm != x509.ECDSA {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate public key algorithm is not ECDSA")
}
// Step 3. Extract the claimed rpIdHash from authenticatorData, and the claimed credentialId and credentialPublicKey
// from authenticatorData.attestedCredentialData.
rpIdHash := att.AuthData.RPIDHash
credentialID := att.AuthData.AttData.CredentialID
// Step 4. Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of RFC8152 [https://www.w3.org/TR/webauthn/#biblio-rfc8152])
// to Raw ANSI X9.62 public key format (see ALG_KEY_ECC_X962_RAW in Section 3.6.2 Public Key
// Representation Formats of
// [FIDO-Registry](https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-registry-v2.0-id-20180227.html#public-key-representation-formats)).
// Let x be the value corresponding to the "-2" key (representing x coordinate) in credentialPublicKey, and confirm
// its size to be of 32 bytes. If size differs or "-2" key is not found, terminate this algorithm and return an
// appropriate error.
// Let y be the value corresponding to the "-3" key (representing y coordinate) in credentialPublicKey, and confirm
// its size to be of 32 bytes. If size differs or "-3" key is not found, terminate this algorithm and return an
// appropriate error.
credentialPublicKey, ok := attCert.PublicKey.(*ecdsa.PublicKey)
if !ok || credentialPublicKey.Curve != elliptic.P256() {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate does not contain a P-256 ECDSA public key")
}
if len(key.XCoord) != 32 || len(key.YCoord) != 32 {
return "", nil, ErrAttestation.WithDetails("X or Y Coordinate for key is invalid length")
}
// Let publicKeyU2F be the concatenation 0x04 || x || y.
publicKeyU2F := bytes.NewBuffer([]byte{0x04})
publicKeyU2F.Write(key.XCoord)
publicKeyU2F.Write(key.YCoord)
// Step 5. Let verificationData be the concatenation of (0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F)
// (see Section 4.3 of [FIDO-U2F-Message-Formats](https://fidoalliance.org/specs/fido-u2f-v1.1-id-20160915/fido-u2f-raw-message-formats-v1.1-id-20160915.html#registration-response-message-success)).
verificationData := bytes.NewBuffer([]byte{0x00})
verificationData.Write(rpIdHash)
verificationData.Write(clientDataHash)
verificationData.Write(credentialID)
verificationData.Write(publicKeyU2F.Bytes())
// Step 6. Verify the sig using verificationData and the certificate public key per section 4.1.4 of [SEC1] with
// SHA-256 as the hash function used in step two.
if err = attCert.CheckSignature(x509.ECDSAWithSHA256, verificationData.Bytes(), sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// TODO: Step 7. Optionally, inspect x5c and consult externally provided knowledge to determine whether attStmt
// conveys a Basic or AttCA attestation.
// Step 8. If successful, return implementation-specific values representing attestation type Basic, AttCA or
// uncertainty, and attestation trust path x5c.
return string(metadata.BasicFull), x5c, nil
}
func init() {
RegisterAttestationFormat(AttestationFormatFIDOUniversalSecondFactor, attestationFormatValidationHandlerFIDOU2F)
}
@@ -0,0 +1,254 @@
package protocol
import (
"bytes"
"crypto/x509"
"encoding/asn1"
"fmt"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
func init() {
RegisterAttestationFormat(AttestationFormatPacked, attestationFormatValidationHandlerPacked)
}
// attestationFormatValidationHandlerPacked is the handler for the Packed Attestation Statement Format.
//
// The syntax of a Packed Attestation statement is defined by the following CDDL:
//
// $$attStmtType //= (
//
// fmt: "packed",
// attStmt: packedStmtFormat
// )
//
// packedStmtFormat = {
// alg: COSEAlgorithmIdentifier,
// sig: bytes,
// x5c: [ attestnCert: bytes, * (caCert: bytes) ]
// } //
// {
// alg: COSEAlgorithmIdentifier
// sig: bytes,
// }
//
// Specification: §8.2. Packed Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-packed-attestation
func attestationFormatValidationHandlerPacked(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
alg int64
sig []byte
x5c []any
ok bool
)
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined
// above and perform CBOR decoding on it to extract the contained fields.
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
// used to generate the attestation signature.
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
// Get the sig value - A byte string containing the attestation signature.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
// Step 2. If x5c is present, this indicates that the attestation type is not ECDAA.
if x5c, ok = att.AttStatement[stmtX5C].([]any); ok {
// Handle Basic Attestation steps for the x509 Certificate.
return handleBasicAttestation(sig, clientDataHash, att.RawAuthData, att.AuthData.AttData.AAGUID, alg, x5c, mds)
}
// Step 3. If ecdaaKeyId is present, then the attestation type is ECDAA.
// Also make sure the we did not have an x509.
ecdaaKeyID, ecdaaKeyPresent := att.AttStatement[stmtECDAAKID].([]byte)
if ecdaaKeyPresent {
// Handle ECDAA Attestation steps for the x509 Certificate.
return handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID, mds)
}
// Step 4. If neither x5c nor ecdaaKeyId is present, self attestation is in use.
return handleSelfAttestation(alg, att.AuthData.AttData.CredentialPublicKey, att.RawAuthData, clientDataHash, sig, mds)
}
// Handle the attestation steps laid out in the basic format.
//
//nolint:gocyclo
func handleBasicAttestation(sig, clientDataHash, authData, aaguid []byte, alg int64, x5c []any, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Step 2.1. Verify that sig is a valid signature over the concatenation of authenticatorData
// and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg.
var attestnCert *x509.Certificate
for i, raw := range x5c {
rawByes, ok := raw.([]byte)
if !ok {
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
cert, err := x509.ParseCertificate(rawByes)
if err != nil {
return "", x5c, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing certificate from ASN.1 data: %+v", err)).WithError(err)
}
if cert.NotBefore.After(time.Now()) || cert.NotAfter.Before(time.Now()) {
return "", x5c, ErrAttestationFormat.WithDetails("Cert in chain is either no longer valid or not yet valid")
}
if i == 0 {
attestnCert = cert
}
}
if attestnCert == nil {
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
signatureData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
} else if err = attestnCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// Step 2.2 Verify that attestnCert meets the requirements in §8.2.1 Packed attestation statement certificate requirements.
// §8.2.1 can be found here https://www.w3.org/TR/webauthn/#packed-attestation-cert-requirements
// Step 2.2.1 (from §8.2.1) Version MUST be set to 3 (which is indicated by an ASN.1 INTEGER with value 2).
if attestnCert.Version != 3 {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate is incorrect version")
}
// Step 2.2.2 (from §8.2.1) Subject field MUST be set to:
// Subject-C
// ISO 3166 code specifying the country where the Authenticator vendor is incorporated (PrintableString).
if len(attestnCert.Subject.Country) != 1 || !isISO3166Alpha2(attestnCert.Subject.Country[0]) {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Country Code is invalid")
}
// Subject-O
// Legal name of the Authenticator vendor (UTF8String).
subjectString := strings.Join(attestnCert.Subject.Organization, "")
if subjectString == "" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organization is invalid")
}
// Subject-OU
// Literal string “Authenticator Attestation” (UTF8String).
subjectString = strings.Join(attestnCert.Subject.OrganizationalUnit, " ")
if subjectString != "Authenticator Attestation" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organizational Unit is invalid")
}
// Subject-CN
// A UTF8String of the vendor’s choosing.
subjectString = attestnCert.Subject.CommonName
if subjectString == "" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Common Name not set")
}
// Step 2.2.3 (from §8.2.1) If the related attestation root certificate is used for multiple authenticator models,
// the Extension OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) MUST be present, containing the
// AAGUID as a 16-byte OCTET STRING. The extension MUST NOT be marked as critical.
var foundAAGUID []byte
for _, extension := range attestnCert.Extensions {
if extension.Id.Equal(oidFIDOGenCeAAGUID) {
if extension.Critical {
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate FIDO extension marked as critical")
}
foundAAGUID = extension.Value
}
}
// We validate the AAGUID as mentioned above
// This is not well defined in§8.2.1 but mentioned in step 2.3: we validate the AAGUID if it is present within the certificate
// and make sure it matches the auth data AAGUID
// Note that an X.509 Extension encodes the DER-encoding of the value in an OCTET STRING. Thus, the
// AAGUID MUST be wrapped in two OCTET STRINGS to be valid.
if len(foundAAGUID) > 0 {
var unMarshalledAAGUID []byte
if _, err = asn1.Unmarshal(foundAAGUID, &unMarshalledAAGUID); err != nil {
return "", x5c, ErrInvalidAttestation.WithDetails("Error unmarshalling AAGUID from certificate")
}
if !bytes.Equal(aaguid, unMarshalledAAGUID) {
return "", x5c, ErrInvalidAttestation.WithDetails("Certificate AAGUID does not match Auth Data certificate")
}
}
// Step 2.2.4 The Basic Constraints extension MUST have the CA component set to false.
if attestnCert.IsCA {
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate's Basic Constraints marked as CA")
}
// Note for 2.2.5 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL
// Distribution Point extension [RFC5280](https://www.w3.org/TR/webauthn/#biblio-rfc5280) are
// both OPTIONAL as the status of many attestation certificates is available through authenticator
// metadata services. See, for example, the FIDO Metadata Service
// [FIDOMetadataService] (https://www.w3.org/TR/webauthn/#biblio-fidometadataservice)
// Step 2.4 If successful, return attestation type Basic and attestation trust path x5c.
// We don't handle trust paths yet but we're done.
return string(metadata.BasicFull), x5c, nil
}
func handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
return "Packed (ECDAA)", nil, ErrNotSpecImplemented
}
func handleSelfAttestation(alg int64, pubKey, authData, clientDataHash, sig []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
verificationData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
var (
key any
valid bool
)
if key, err = webauthncose.ParsePublicKey(pubKey); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the public key: %+v", err))
}
// §4.1 Validate that alg matches the algorithm of the credentialPublicKey in authenticatorData.
switch k := key.(type) {
case webauthncose.OKPPublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
case webauthncose.EC2PublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
case webauthncose.RSAPublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
default:
return "", nil, ErrInvalidAttestation.WithDetails("Error verifying the public key data")
}
if err != nil {
return "", nil, err
}
// §4.2 Verify that sig is a valid signature over the concatenation of authenticatorData and
// clientDataHash using the credential public key with alg.
if valid, err = webauthncose.VerifySignature(key, verificationData, sig); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error verifying the signature: %+v", err)).WithError(err)
} else if !valid {
return "", nil, ErrInvalidAttestation.WithDetails("Unable to verify signature")
}
return string(metadata.BasicSurrogate), nil, err
}
func verifyKeyAlgorithm(keyAlgorithm, attestedAlgorithm int64) error {
if keyAlgorithm != attestedAlgorithm {
return ErrInvalidAttestation.WithDetails("Public key algorithm does not equal att statement algorithm")
}
return nil
}
@@ -0,0 +1,196 @@
package protocol
import (
"bytes"
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/golang-jwt/jwt/v5"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// attestationFormatValidationHandlerAndroidSafetyNet is the handler for the Android SafetyNet Attestation Statement
// Format.
//
// When the authenticator is a platform authenticator on certain Android platforms, the attestation statement may be
// based on the SafetyNet API. In this case the authenticator data is completely controlled by the caller of the
// SafetyNet API (typically an application running on the Android platform) and the attestation statement provides some
// statements about the health of the platform and the identity of the calling application (see SafetyNet Documentation
// for more details).
//
// The syntax of an Android Attestation statement is defined as follows:
//
// $$attStmtType //= (
// fmt: "android-safetynet",
// attStmt: safetynetStmtFormat
// )
//
// safetynetStmtFormat = {
// ver: text,
// response: bytes
// }
//
// Specification: §8.5. Android SafetyNet Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerAndroidSafetyNet(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
// The syntax of an Android Attestation statement is defined as follows:
// $$attStmtType //= (
// fmt: "android-safetynet",
// attStmt: safetynetStmtFormat
// )
// safetynetStmtFormat = {
// ver: text,
// response: bytes
// }
// §8.5.1 Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
// the contained fields.
// We have done this
// §8.5.2 Verify that response is a valid SafetyNet response of version ver.
version, present := att.AttStatement[stmtVersion].(string)
if !present {
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the version of SafetyNet")
}
if version == "" {
return "", nil, ErrAttestationFormat.WithDetails("Not a proper version for SafetyNet")
}
// TODO: provide user the ability to designate their supported versions.
response, present := att.AttStatement["response"].([]byte)
if !present {
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the SafetyNet response")
}
var token *jwt.Token
if token, err = jwt.Parse(string(response), keyFuncSafetyNetJWT, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// marshall the JWT payload into the safetynet response json.
var safetyNetResponse SafetyNetResponse
if err = mapstructure.Decode(token.Claims, &safetyNetResponse); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the SafetyNet response: %+v", err)).WithError(err)
}
// §8.5.3 Verify that the nonce in the response is identical to the Base64 encoding of the SHA-256 hash of the concatenation
// of authenticatorData and clientDataHash.
nonceBuffer := sha256.Sum256(append(att.RawAuthData, clientDataHash...))
nonceBytes, err := base64.StdEncoding.DecodeString(safetyNetResponse.Nonce)
if !bytes.Equal(nonceBuffer[:], nonceBytes) || err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Invalid nonce for in SafetyNet response").WithError(err)
}
// §8.5.4 Let attestationCert be the attestation certificate (https://www.w3.org/TR/webauthn/#attestation-certificate)
certChain, ok := token.Header[stmtX5C].([]any)
if !ok || len(certChain) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("Error getting certificate from JWT header x5c")
}
first, ok := certChain[0].(string)
if !ok || first == "" {
return "", nil, ErrInvalidAttestation.WithDetails("Error getting first certificate from JWT header x5c")
}
l := make([]byte, base64.StdEncoding.DecodedLen(len(first)))
n, err := base64.StdEncoding.Decode(l, []byte(first))
if err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
attestationCert, err := x509.ParseCertificate(l[:n])
if err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// §8.5.5 Verify that attestationCert is issued to the hostname "attest.android.com".
if err = attestationCert.VerifyHostname(attStatementAndroidSafetyNetHostname); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// §8.5.6 Verify that the ctsProfileMatch attribute in the payload of response is true.
if !safetyNetResponse.CtsProfileMatch {
return "", nil, ErrInvalidAttestation.WithDetails("ctsProfileMatch attribute of the JWT payload is false")
}
if t := time.Unix(safetyNetResponse.TimestampMs/1000, 0); t.After(time.Now()) {
// Zero tolerance for post-dated timestamps.
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp after current time")
} else if t.Before(time.Now().Add(-time.Minute)) {
// Small tolerance for pre-dated timestamps.
if mds != nil && mds.GetValidateEntry(context.Background()) {
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp before one minute ago")
}
}
// §8.5.7 If successful, return implementation-specific values representing attestation type Basic and attestation
// trust path attestationCert.
return string(metadata.BasicFull), nil, nil
}
func keyFuncSafetyNetJWT(token *jwt.Token) (key any, err error) {
var (
ok bool
raw any
chain []any
first string
der []byte
cert *x509.Certificate
)
if raw, ok = token.Header[stmtX5C]; !ok {
return nil, fmt.Errorf("jwt header missing x5c")
}
if chain, ok = raw.([]any); !ok || len(chain) == 0 {
return nil, fmt.Errorf("jwt header x5c is not a non-empty array")
}
if first, ok = chain[0].(string); !ok || first == "" {
return nil, fmt.Errorf("jwt header x5c[0] not a base64 string")
}
if der, err = base64.StdEncoding.DecodeString(first); err != nil {
return nil, fmt.Errorf("decode x5c leaf: %w", err)
}
if cert, err = x509.ParseCertificate(der); err != nil {
if cert != nil {
return cert.PublicKey, fmt.Errorf("parse x5c leaf: %w", err)
}
return nil, fmt.Errorf("parse x5c leaf: %w", err)
}
return cert.PublicKey, nil
}
type SafetyNetResponse struct {
Nonce string `json:"nonce"`
TimestampMs int64 `json:"timestampMs"`
ApkPackageName string `json:"apkPackageName"`
ApkDigestSha256 string `json:"apkDigestSha256"`
CtsProfileMatch bool `json:"ctsProfileMatch"`
ApkCertificateDigestSha256 []any `json:"apkCertificateDigestSha256"`
BasicIntegrity bool `json:"basicIntegrity"`
}
func init() {
RegisterAttestationFormat(AttestationFormatAndroidSafetyNet, attestationFormatValidationHandlerAndroidSafetyNet)
}
@@ -0,0 +1,635 @@
package protocol
import (
"bytes"
"crypto"
"crypto/subtle"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/google/go-tpm/tpm2"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerTPM is the handler for the TPM Attestation Statement Format.
//
// The syntax of a TPM Attestation statement is as follows:
//
// $$attStmtType // = (
//
// fmt: "tpm",
// attStmt: tpmStmtFormat
// )
//
// tpmStmtFormat = {
// ver: "2.0",
// (
// alg: COSEAlgorithmIdentifier,
// x5c: [ aikCert: bytes, * (caCert: bytes) ]
// )
// sig: bytes,
// certInfo: bytes,
// pubArea: bytes
// }
//
// Specification: §8.3. TPM Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-tpm-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerTPM(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
var statement *tpm2AttStatement
if statement, err = newTPM2AttStatement(att.AttStatement); err != nil {
return "", nil, err
}
if statement.HasECDAAKeyID || statement.HasValidECDAAKeyID {
return "", nil, ErrNotImplemented
}
if !statement.HasX5C || !statement.HasValidX5C {
return "", nil, ErrNotImplemented
}
if statement.Version != versionTPM20 {
return "", nil, ErrAttestationFormat.WithDetails("WebAuthn only supports TPM 2.0 currently")
}
var (
pubArea *tpm2.TPMTPublic
key any
)
if pubArea, err = tpm2.Unmarshal[tpm2.TPMTPublic](statement.PubArea); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to decode TPMT_PUBLIC in attestation statement").WithError(err)
}
if key, err = webauthncose.ParsePublicKey(att.AuthData.AttData.CredentialPublicKey); err != nil {
return "", nil, err
}
switch k := key.(type) {
case webauthncose.EC2PublicKeyData:
var (
params *tpm2.TPMSECCParms
point *tpm2.TPMSECCPoint
)
if params, err = pubArea.Parameters.ECCDetail(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if point, err = pubArea.Unique.ECC(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if params.CurveID != k.TPMCurveID() {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if !bytes.Equal(point.X.Buffer, k.XCoord) || !bytes.Equal(point.Y.Buffer, k.YCoord) {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
case webauthncose.RSAPublicKeyData:
var (
params *tpm2.TPMSRSAParms
modulus *tpm2.TPM2BPublicKeyRSA
)
if params, err = pubArea.Parameters.RSADetail(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
if modulus, err = pubArea.Unique.RSA(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
if !bytes.Equal(modulus.Buffer, k.Modulus) {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
exp := uint32(k.Exponent[0]) + uint32(k.Exponent[1])<<8 + uint32(k.Exponent[2])<<16
if tpm2Exponent(params) != exp {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
default:
return "", nil, ErrUnsupportedKey
}
// Concatenate authenticatorData and clientDataHash to form attToBeSigned.
attToBeSigned := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
var certInfo *tpm2.TPMSAttest
// Validate that certInfo is valid:
// 1/4 Verify that magic is set to TPM_GENERATED_VALUE, handled here.
if certInfo, err = tpm2.Unmarshal[tpm2.TPMSAttest](statement.CertInfo); err != nil {
return "", nil, err
}
if err = certInfo.Magic.Check(); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Magic is not set to TPM_GENERATED_VALUE")
}
// 2/4 Verify that type is set to TPM_ST_ATTEST_CERTIFY.
if certInfo.Type != tpm2.TPMSTAttestCertify {
return "", nil, ErrAttestationFormat.WithDetails("Type is not set to TPM_ST_ATTEST_CERTIFY")
}
// 3/4 Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg".
coseAlg := webauthncose.COSEAlgorithmIdentifier(statement.Algorithm)
h := webauthncose.HasherFromCOSEAlg(coseAlg)
h.Write(attToBeSigned)
if !bytes.Equal(certInfo.ExtraData.Buffer, h.Sum(nil)) {
return "", nil, ErrAttestationFormat.WithDetails("ExtraData is not set to hash of attToBeSigned")
}
// Note that the remaining fields in the "Standard Attestation Structure"
// [TPMv2-Part1] section 31.2, i.e., qualifiedSigner, clockInfo and firmwareVersion
// are ignored. These fields MAY be used as an input to risk engines.
var (
aikCert *x509.Certificate
raw []byte
ok bool
)
if len(statement.X5C) == 0 {
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
// In this case:
// Verify the sig is a valid signature over certInfo using the attestation public key in aikCert with the algorithm specified in alg.
if raw, ok = statement.X5C[0].([]byte); !ok {
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
if aikCert, err = x509.ParseCertificate(raw); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1")
}
if sigAlg := webauthncose.SigAlgFromCOSEAlg(coseAlg); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", statement.Algorithm))
} else if err = aikCert.CheckSignature(sigAlg, statement.CertInfo, statement.Signature); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Signature validation error: %+v", err))
}
// Verify that aikCert meets the requirements in §8.3.1 TPM Attestation Statement Certificate Requirements.
// 1/6 Version MUST be set to 3.
if aikCert.Version != 3 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate version must be 3")
}
// 2/6 Subject field MUST be set to empty.
if aikCert.Subject.String() != "" {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate subject must be empty")
}
var (
manufacturer, model, version string
ekuValid = false
eku []asn1.ObjectIdentifier
constraints tpmBasicConstraints
rest []byte
)
for _, ext := range aikCert.Extensions {
switch {
case ext.Id.Equal(oidExtensionSubjectAltName):
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
return "", nil, err
}
case ext.Id.Equal(oidExtensionExtendedKeyUsage):
if rest, err = asn1.Unmarshal(ext.Value, &eku); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage malformed")
} else if len(rest) != 0 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage contains extra data")
}
found := false
for _, oid := range eku {
if oid.Equal(oidTCGKpAIKCertificate) {
found = true
break
}
}
if !found {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage missing 2.23.133.8.3")
}
ekuValid = true
case ext.Id.Equal(oidExtensionBasicConstraints):
if rest, err = asn1.Unmarshal(ext.Value, &constraints); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints malformed")
} else if len(rest) != 0 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints contains extra data")
}
}
}
// 3/6 The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.
if manufacturer == "" || model == "" || version == "" {
return "", nil, ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate")
}
if !isValidTPMManufacturer(manufacturer) {
return "", nil, ErrAttestationFormat.WithDetails("Invalid TPM manufacturer")
}
// 4/6 The Extended Key Usage extension MUST contain the "joint-iso-itu-t(2) internationalorganizations(23) 133 tcg-kp(8) tcg-kp-AIKCertificate(3)" OID.
if !ekuValid {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate missing EKU")
}
// 6/6 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point
// extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available
// through metadata services. See, for example, the FIDO Metadata Service.
if constraints.IsCA {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints missing or CA is true")
}
// 4/4 Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in
// [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea,
// as computed using the algorithm in the nameAlg field of pubArea
// using the procedure specified in [TPMv2-Part1] section 16.
//
// This needs to move after the x5c check as the QualifiedSigner only gets populated when it can be verified.
if ok, err = tpm2NameMatch(certInfo, pubArea); err != nil {
return "", nil, err
} else if !ok {
return "", nil, ErrAttestationFormat.WithDetails("Hash value mismatch attested and pubArea")
}
return string(metadata.AttCA), statement.X5C, err
}
func tpm2Exponent(params *tpm2.TPMSRSAParms) (exp uint32) {
if params.Exponent != 0 {
return params.Exponent
}
return 65537
}
func tpm2NameMatch(certInfo *tpm2.TPMSAttest, pubArea *tpm2.TPMTPublic) (match bool, err error) {
if certInfo == nil || pubArea == nil {
return false, nil
}
var (
certifyInfo *tpm2.TPMSCertifyInfo
name *tpm2.TPM2BName
)
if certifyInfo, err = certInfo.Attested.Certify(); err != nil {
return false, err
}
if name, err = tpm2.ObjectName(pubArea); err != nil {
return false, err
}
// Per the WebAuthn Specification §8.3 step 5:
//
// Note: The remaining fields in the "Standard Attestation Structure" [TPMv2-Part1] section 31.2, i.e.,
// qualifiedSigner, clockInfo and firmwareVersion are ignored. Depending on the properties of the aikCert key used,
// these fields may be obfuscated. If valid, these MAY be used as an input to risk engines.
//
// See: https://w3c.github.io/webauthn/#sctn-tpm-attestation
return subtle.ConstantTimeCompare(certifyInfo.Name.Buffer, name.Buffer) == 1, nil
}
func tpm2NameDigest(name tpm2.TPM2BName) (alg tpm2.TPMIAlgHash, digest []byte, err error) {
buf := name.Buffer
if len(buf) < 3 {
return 0, nil, fmt.Errorf("name too short")
}
alg = tpm2.TPMIAlgHash(binary.BigEndian.Uint16(buf[:2]))
var hash crypto.Hash
if hash, err = alg.Hash(); err != nil {
return 0, nil, fmt.Errorf("invalid hash algorithm: %w", err)
}
digest = buf[2:]
if len(digest) == 0 {
return 0, nil, fmt.Errorf("name digest is empty")
}
if len(digest) != hash.Size() {
return 0, nil, fmt.Errorf("invalid name digest length: %d", len(digest))
}
return alg, digest, nil
}
type tpm2AttStatement struct {
Version string
Algorithm int64
Signature []byte
CertInfo []byte
PubArea []byte
X5C []any
HasX5C bool
HasValidX5C bool
HasECDAAKeyID bool
HasValidECDAAKeyID bool
ECDAAKeyID []byte
}
func newTPM2AttStatement(raw map[string]any) (statement *tpm2AttStatement, err error) {
var ok bool
statement = &tpm2AttStatement{}
// Given the verification procedure inputs attStmt, authenticatorData
// and clientDataHash, the verification procedure is as follows.
// Verify that attStmt is valid CBOR conforming to the syntax defined
// above and perform CBOR decoding on it to extract the contained fields.
if statement.Version, ok = raw[stmtVersion].(string); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving ver value")
}
if statement.Algorithm, ok = raw[stmtAlgorithm].(int64); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
if statement.Signature, ok = raw[stmtSignature].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
if statement.CertInfo, ok = raw[stmtCertInfo].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving certInfo value")
}
if statement.PubArea, ok = raw[stmtPubArea].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving pubArea value")
}
var rawX5C, rawECDAAKeyID any
rawX5C, statement.HasX5C = raw[stmtX5C]
statement.X5C, statement.HasValidX5C = rawX5C.([]any)
rawECDAAKeyID, statement.HasECDAAKeyID = raw[stmtECDAAKID]
statement.ECDAAKeyID, statement.HasValidECDAAKeyID = rawECDAAKeyID.([]byte)
return statement, nil
}
// forEachSAN loops through the TPM SAN extension.
//
// RFC 5280, 4.2.1.6
// SubjectAltName ::= GeneralNames
//
// GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
//
// GeneralName ::= CHOICE {
// otherName [0] OtherName,
// rfc822Name [1] IA5String,
// dNSName [2] IA5String,
// x400Address [3] ORAddress,
// directoryName [4] Name,
// ediPartyName [5] EDIPartyName,
// uniformResourceIdentifier [6] IA5String,
// iPAddress [7] OCTET STRING,
// registeredID [8] OBJECT IDENTIFIER }
func forEachSAN(extension []byte, callback func(tag int, data []byte) error) error {
var seq asn1.RawValue
rest, err := asn1.Unmarshal(extension, &seq)
if err != nil {
return err
} else if len(rest) != 0 {
return errors.New("x509: trailing data after X.509 extension")
}
if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 {
return asn1.StructuralError{Msg: "bad SAN sequence"}
}
rest = seq.Bytes
for len(rest) > 0 {
var v asn1.RawValue
rest, err = asn1.Unmarshal(rest, &v)
if err != nil {
return err
}
if err = callback(v.Tag, v.Bytes); err != nil {
return err
}
}
return nil
}
const (
nameTypeDN = 4
)
func parseSANExtension(value []byte) (manufacturer string, model string, version string, err error) {
err = forEachSAN(value, func(tag int, data []byte) error {
if tag == nameTypeDN {
tpmDeviceAttributes := pkix.RDNSequence{}
if _, err = asn1.Unmarshal(data, &tpmDeviceAttributes); err != nil {
return err
}
for _, rdn := range tpmDeviceAttributes {
if len(rdn) == 0 {
continue
}
for _, atv := range rdn {
value, ok := atv.Value.(string)
if !ok {
continue
}
if atv.Type.Equal(oidTCGAtTpmManufacturer) {
manufacturer = strings.TrimPrefix(value, "id:")
}
if atv.Type.Equal(oidTCGAtTpmModel) {
model = value
}
if atv.Type.Equal(oidTCGAtTPMVersion) {
version = strings.TrimPrefix(value, "id:")
}
}
}
}
return nil
})
return
}
type tpmManufacturer struct {
id string
name string
code string
}
// See https://trustedcomputinggroup.org/resource/vendor-id-registry/ for registry contents.
var (
tpmManufacturers = []tpmManufacturer{
{"414D4400", "AMD", "AMD"},
{"414E5400", "Ant Group", "ANT"},
{"41544D4C", "Atmel", "ATML"},
{"4252434D", "Broadcom", "BRCM"},
{"4353434F", "Cisco", "CSCO"},
{"464C5953", "Flyslice Technologies", "FLYS"},
{"524F4343", "Fuzhou Rockchip", "ROCC"},
{"474F4F47", "Google", "GOOG"},
{"48504900", "HPI", "HPI"},
{"48504500", "HPE", "HPE"},
{"48495349", "Huawei", "HISI"},
{"49424d00", "IBM", "IBM"},
{"49424D00", "IBM", "IBM"},
{"49465800", "Infineon", "IFX"},
{"494E5443", "Intel", "INTC"},
{"4C454E00", "Lenovo", "LEN"},
{"4D534654", "Microsoft", "MSFT"},
{"4E534D20", "National Semiconductor", "NSM"},
{"4E545A00", "Nationz", "NTZ"},
{"4E534700", "NSING", "NSG"},
{"4E544300", "Nuvoton Technology", "NTC"},
{"51434F4D", "Qualcomm", "QCOM"},
{"534D534E", "Samsung", "SECE"},
{"53454345", "SecEdge", "SecEdge"},
{"534E5300", "Sinosun", "SNS"},
{"534D5343", "SMSC", "SMSC"},
{"53544D20", "ST Microelectronics", "STM"},
{"54584E00", "Texas Instruments", "TXN"},
{"57454300", "Winbond", "WEC"},
{"5345414C", "Wisekey", "SEAL"},
{"FFFFF1D0", "FIDO Alliance Conformance Testing", "FIDO"},
}
)
func isValidTPMManufacturer(id string) bool {
for _, m := range tpmManufacturers {
if m.id == id {
return true
}
}
return false
}
func tpmParseAIKAttCA(x5c *x509.Certificate, x5cis []*x509.Certificate) (err *Error) {
if err = tpmParseSANExtension(x5c); err != nil {
return err
}
if err = tpmRemoveEKU(x5c); err != nil {
return err
}
for _, parent := range x5cis {
if err = tpmRemoveEKU(parent); err != nil {
return err
}
}
return nil
}
func tpmParseSANExtension(attestation *x509.Certificate) (protoErr *Error) {
var (
manufacturer, model, version string
err error
)
for _, ext := range attestation.Extensions {
if ext.Id.Equal(oidExtensionSubjectAltName) {
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
return ErrInvalidAttestation.WithDetails("Authenticator with invalid Authenticator Identity Key SAN data encountered during attestation validation.").WithInfo(fmt.Sprintf("Error occurred parsing SAN extension: %s", err.Error())).WithError(err)
}
}
}
if manufacturer == "" || model == "" || version == "" {
return ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate.")
}
var unhandled []asn1.ObjectIdentifier
for _, uce := range attestation.UnhandledCriticalExtensions {
if uce.Equal(oidExtensionSubjectAltName) {
continue
}
unhandled = append(unhandled, uce)
}
attestation.UnhandledCriticalExtensions = unhandled
return nil
}
type tpmBasicConstraints struct {
IsCA bool `asn1:"optional"`
MaxPathLen int `asn1:"optional,default:-1"`
}
// Remove extension key usage to avoid ExtKeyUsage check failure.
func tpmRemoveEKU(x5c *x509.Certificate) *Error {
var (
unknown []asn1.ObjectIdentifier
hasAiK bool
)
for _, eku := range x5c.UnknownExtKeyUsage {
if eku.Equal(oidTCGKpAIKCertificate) {
hasAiK = true
continue
}
if eku.Equal(oidMicrosoftKpPrivacyCA) {
continue
}
unknown = append(unknown, eku)
}
if !hasAiK {
return ErrAttestationFormat.WithDetails("Attestation Identity Key certificate missing required Extended Key Usage.")
}
x5c.UnknownExtKeyUsage = unknown
return nil
}
func init() {
RegisterAttestationFormat(AttestationFormatTPM, attestationFormatValidationHandlerTPM)
}

Some files were not shown because too many files have changed in this diff Show More