Documentation
¶
Overview ¶
Package webauthnx wraps github.com/go-webauthn/webauthn with the apic-specific identity binding, FedRAMP-compatible attestation policy (AAGUID allow-list), and ceremony orchestration. Sister packages: pkg/securex/signerx (Plan 04) provides the JWT signer used for step-up tokens; pkg/securex/sessionx (Plan 05) enforces AC-7/11/12.
Index ¶
- Variables
- func HTTPStatus(err error) int
- func HexDecodeAAGUID(s string) []byte
- func IssueStepUpJWT(signer crypto.Signer, alg jwa.SignatureAlgorithm, claims StepUpClaims) (string, error)
- func RequireAMR(required ...string) func(http.Handler) http.Handler
- type AAGUIDPolicy
- type BeginLoginOut
- type BeginRegistrationOut
- type Config
- type CredentialStore
- type FinishLoginEvent
- type FinishLoginOut
- type FinishRegistrationOut
- type OnLoginVerifiedHook
- type RecordingResponseWriter
- type Server
- func (s *Server) BeginLogin(ctx context.Context, tenantID, owner string, userID []byte) (*BeginLoginOut, error)
- func (s *Server) BeginRegistration(ctx context.Context, tenantID, owner string, u User) (*BeginRegistrationOut, error)
- func (s *Server) FinishLogin(ctx context.Context, tenantID, owner, sessionID string, body []byte) (*FinishLoginOut, error)
- func (s *Server) FinishRegistration(ctx context.Context, tenantID, owner, sessionID string, body []byte) (*FinishRegistrationOut, error)
- func (s *Server) RPID() string
- type SessionStore
- type StepUpClaims
- type User
Constants ¶
This section is empty.
Variables ¶
var ( ErrSessionConsumed = errors.New("webauthnx: session already consumed") ErrSessionExpired = errors.New("webauthnx: session expired") ErrSessionNotFound = errors.New("webauthnx: session not found") )
Sentinel errors.
var ErrAAGUIDNotAllowed = errors.New("webauthnx: authenticator AAGUID not in allow-list")
ErrAAGUIDNotAllowed is returned by AAGUIDPolicy.Enforce when the authenticator's AAGUID is not in the allow-list, OR when the credential's attestation does not itself vouch for that AAGUID (see the R9-1 fail-closed check below).
var ErrCredentialAlreadyRegistered = errors.New("webauthnx: credential ID already registered to another user")
ErrCredentialAlreadyRegistered is returned by FinishRegistration when the presented credential ID is already bound to a DIFFERENT user (W3C WebAuthn L3 §7.1 step 27: "verify that the credentialId is not yet registered for any user"). The credential ID is fully attacker-chosen (the authenticator/client picks it, per §5.4.3), so without this check an attacker can enroll their own key under a victim's existing credential ID; FinishLogin's discoverable-credential resolution and the CredentialStore.GetByID contract then depend entirely on which user's bucket that ID happens to be found in first (R10-1). Checked AFTER go-webauthn verifies the attestation (so the credential's identity is authenticated) but BEFORE it is persisted.
var ErrCredentialNotFound = errors.New("webauthnx: credential not found")
ErrCredentialNotFound is returned by GetByID/Delete/UpdateSignCount when no row matches.
var ErrEmptyOwner = errors.New("webauthnx: empty registration owner or user handle")
ErrEmptyOwner is returned by BeginRegistration when either owner or u.ID is empty (R11-3). This is defense-in-depth: the generation-time validator requires an identity auth mode (jwt/cookie, or a composite with one in every OR-group) on every ceremony:"register" route so the emitted handler always has a non-empty JWT-subject owner and WebAuthn user handle to pass in. But should that guard ever be bypassed or a caller invoke this method directly with an empty owner, admitting the ceremony would mint a registration bound to nothing — GAP-0057's FinishRegistration owner-match guard (`sessOwner != owner`) and R10-1's cross-user uniqueness check both degenerate to comparing two zero values and always pass, letting an unauthenticated caller silently overwrite whatever credential a previous empty-owner registration stored. Refusing here keeps that guard meaningful even under a zero-valued (not absent) input.
var ErrEmptyTenant = errors.New("webauthnx: empty tenant id")
ErrEmptyTenant is returned by every CredentialStore / SessionStore method when the supplied tenantID is empty. Tenant scoping is mandatory — silently accepting "" would collapse all callers into a single shared bucket and break tenant isolation (REQ-0013).
var ErrInvalidConfig = errors.New("webauthnx: invalid config")
ErrInvalidConfig is the validation sentinel for NewServer.
var ErrOwnerMismatch = errors.New("webauthnx: registration session owner mismatch")
ErrOwnerMismatch is returned by FinishRegistration when the authenticated caller's subject does not match the ceremony session's recorded owner. Prevents the GAP-0057 ceremony-splice attack: an attacker who obtains a sessionID for another user's in-flight registration cannot bind a passkey to that other account. Reference: devnw.dev/oidc webauthn.go:253.
var ErrSignCountRegression = errors.New("webauthnx: sign count regression — possible cloned authenticator")
ErrSignCountRegression is returned by UpdateSignCount when the proposed count is not strictly greater than the stored value (unless both are zero, which is the W3C "authenticator does not implement sign counter" sentinel). A regression is a strong signal of a cloned authenticator and MUST be treated as an authentication failure.
var ErrStepUpInvalidClaims = errors.New("webauthnx: step-up claims missing required fields")
ErrStepUpInvalidClaims is returned by IssueStepUpJWT when the supplied StepUpClaims fails the required-field gate (Subject and at least one AMR value). Surfaces as a 4xx upstream — the caller gave us an unsignable assertion, not a key/IO failure.
var ErrWebAuthnStoresRequired = errors.New(
"webauthnx: WebAuthnCredentialStore and WebAuthnSessionStore are required when any route declares a webauthn_* profile",
)
ErrWebAuthnStoresRequired is the boot-time sentinel returned by the apic-generated *webauthnx.Server bootstrap when the consumer fails to supply both APIOptions.WebAuthnCredentialStore and APIOptions.WebAuthnSessionStore for a server that declares at least one route with a webauthn_* profile.
Per docs/WEBAUTHN_MODE.md § Storage contract, apic does NOT ship durable stores; the APIOptions fields are the boundary where consumers plug in their own implementation. Failing closed at registration time prevents a forgetful operator from shipping a WebAuthn surface with no persistence — the alternative (lazy nil-deref on first ceremony call) would surface only under traffic and obscure the misconfig.
Functions ¶
func HTTPStatus ¶
HTTPStatus maps a webauthnx sentinel to the HTTP status code the generated ceremony handler should emit. Used by the apic-generated UnimplementedServer method bodies for the four webauthn_* profiles (Task 4.2 / GENWA-R1) so the sentinel→status mapping lives in one place and not inlined four times in emitted source.
Mapping summary:
ErrOwnerMismatch → 403 (caller is not the ceremony owner; GAP-0057)
ErrAAGUIDNotAllowed → 403 (authenticator not in operator allow-list)
ErrCredentialAlreadyRegistered → 409 (credential ID already bound to another user; R10-1)
ErrSignCountRegression → 401 (possible cloned authenticator)
ErrSessionNotFound → 400 (unknown/consumed session id)
ErrSessionExpired → 400 (TTL elapsed)
ErrSessionConsumed → 400 (one-shot session already used)
ErrCredentialNotFound → 404 (credential lookup miss; rare path)
ErrEmptyTenant → 400 (caller did not scope the ceremony to a tenant)
ErrEmptyOwner → 400 (BeginRegistration called with no owner/user handle; R11-3)
ErrInvalidConfig → 500 (boot-time config drift surfaced at runtime)
*protocol.Error → 400 (go-webauthn's own error type for an
ordinary, expected ceremony failure — bad/
mismatched signature, expired challenge,
malformed assertion, unknown credential —
none of which are a server malfunction)
default → 500
Returns 200 for nil to make the helper safe to call unconditionally.
func HexDecodeAAGUID ¶
HexDecodeAAGUID parses one AAGUID hex string into its 16-byte binary form. Returns nil for empty input or invalid hex so the generated server bootstrap can carry an inline slice of decoded AAGUIDs even when the config (or downstream consumer) leaves an allow-list entry blank or malformed — there is no configx validator that rejects a malformed AAGUID hex string up front (no such check exists in cmd/apic today), so every failure mode here is handled fail-closed at this layer instead: a nil decode result can never byte-match a real 16-byte AAGUID in AAGUIDPolicy.Enforce's comparison, so a bad entry silently excludes itself from the allow-list rather than panicking or admitting an unintended credential. This helper exists so the emitted code stays string-shaped — the security.webauthn.aaguid_allow_list block flows through as []string literals, and the generated bootstrap converts each to []byte at boot via HexDecodeAAGUID.
func IssueStepUpJWT ¶
func IssueStepUpJWT(signer crypto.Signer, alg jwa.SignatureAlgorithm, claims StepUpClaims) (string, error)
IssueStepUpJWT signs a step-up JWT carrying the claims required by RFC 8176 (sub + amr) plus the standard issuance/expiry pair. The returned compact-serialized JWS is what apic's WebAuthn FinishLogin handler returns to the client; subsequent calls to RequireAMR-gated routes present the same token and the middleware admits when the amr claim matches.
Algorithm selection is the caller's responsibility — pass the jwa value matching the signer (RS256 for *rsa.PrivateKey, ES256 for a P-256 *ecdsa.PrivateKey, EdDSA for ed25519, etc.). FIPS deployments MUST use a FIPS-validated backend (pkcs11, awskms, azurekv); non-FIPS may use softfile. See pkg/securex/signerx for the backend contract.
func RequireAMR ¶
RequireAMR returns net/http middleware that admits a request only if the verified JWT in r.Context() carries an `amr` claim containing AT LEAST ONE of the supplied method references (RFC 8176 §1 — the claim is a JSON array of case-sensitive strings). A request with no claims, no `amr`, or no matching `amr` returns HTTP 403 with the generic forbidden envelope (no leakage of which AMR was required).
Step-up gating is layered ON TOP OF authentication: the upstream auth middleware MUST have parsed the bearer token, verified its signature, and populated the Claims pointer via securex.ContextWithClaims. RequireAMR is intentionally fail-closed — a request with no claims in context returns 403, not 401, because the caller already passed an "authenticated" gate (the no-claims state is "auth ran, claims absent", which is a configuration error on the server, not the caller's fault). 403 is the right class because the SEMANTICS are "your auth is insufficient for this resource", not "your auth was rejected".
Apic-emitted servers wire this from a route's required_amr config field (Phase 4 of the WebAuthn gaps plan): a route that declares `required_amr: ["webauthn"]` gets RequireAMR("webauthn") chained after the route's normal auth middleware.
Calling RequireAMR with no required values returns a no-op middleware that admits every request — this matches the "no gate configured" config-time default and avoids a confusing "everything fails" misconfiguration.
Types ¶
type AAGUIDPolicy ¶
type AAGUIDPolicy struct {
Allow [][]byte
// Roots is the trust-anchor pool used to verify an x5c-carrying
// attestation leaf certificate (R10-2). REQUIRED whenever Allow is
// non-empty — NewServer refuses to construct a Server with a
// non-empty AAGUIDAllowList and a nil AttestationRoots, because
// without it Enforce cannot distinguish a genuinely vendor-attested
// authenticator from a self-signed forgery, making the allow-list a
// theatre check on the reported AAGUID alone.
Roots *x509.CertPool
}
AAGUIDPolicy enforces FedRAMP / NIST 800-63B AAL3 authenticator allow-lists. Empty Allow accepts everything (relevant for AAL1/AAL2 or for development).
R9-1 / R10-2: an allow-list is only as trustworthy as the AAGUID it compares against, and that AAGUID is only as trustworthy as the attestation that goes with it. go-webauthn populates webauthn.Credential.Authenticator.AAGUID straight from the registration response's attested credential data — a field the AUTHENTICATOR reports about itself:
- "none" attestation (metadata.None) and packed SELF attestation ("basic_surrogate": signed with the credential's own, registrant-controlled private key, per protocol/attestation_packed.go's handleSelfAttestation) carry NO external vouching for the AAGUID at all — the registrant fully controls both. Enforce fails closed on both shapes outright (R9-1, below).
- EVERY OTHER attestation type go-webauthn actually implements (basic_full/attca/anonca — anything with an x5c chain) is, BY ITSELF, no better: go-webauthn's own handleBasicAttestation (protocol/attestation_packed.go step 2.4) explicitly does not validate the x5c chain against any trust anchor ("We don't handle trust paths yet but we're done"), and wiring a metadata.Provider through Config.MetadataProvider does not close that gap either: go-webauthn's ValidateMetadata (protocol/metadata.go) skips x5c.Verify entirely whenever the attestation leaf is SELF-SIGNED (Subject.CommonName == Issuer.CommonName) — exactly the certificate shape an attacker mints trivially (generate a keypair, self-sign a cert meeting §8.2.1's shape requirements: version 3, ISO-3166 Subject.C, non-empty Subject.O, Subject.OU == "Authenticator Attestation", non-empty Subject.CN, IsCA:false). Without a chain check of its own this package inherits that same gap, at the cost of only "generate a self-signed cert with a fixed subject" instead of "flip 16 bytes" (R9-1's fix). Roots (below) closes it: Enforce independently re-parses the attestation object and verifies the x5c leaf against a real, operator-supplied trust anchor, rejecting a self-signed leaf unconditionally regardless of what MetadataProvider/mds does or doesn't do.
func (AAGUIDPolicy) Enforce ¶
func (p AAGUIDPolicy) Enforce(c webauthn.Credential) error
Enforce returns ErrAAGUIDNotAllowed when the credential's AAGUID is not in Allow, or when Allow is non-empty and the credential's attestation does not itself provide a genuine, trust-anchor-verified authenticator-binding guarantee for that AAGUID:
- "none" attestation and packed self-attestation are rejected outright (R9-1) — nothing external vouches for the AAGUID at all.
- every other (x5c-carrying) attestation type has its leaf certificate re-verified against Roots (R10-2), and a self-signed leaf is rejected unconditionally regardless of what that verification would otherwise conclude.
Precise scope of what the chain check proves (do not overstate it): a verified chain proves "this is an authenticator whose attestation certificate chains to a configured root" — i.e. a genuine batch certificate from a vendor you trust — NOT "an authenticator with this specific AAGUID". verifyAttestationChain does not itself bind the two: it does not inspect the leaf's id-fido-gen-ce-aaguid extension (FIDO2 §8.2.1) to confirm the certificate attests to the AAGUID being compared below, and go-webauthn's own check of that extension (protocol/attestation_packed.go) is conditional, not something this package can rely on having run. Once the chain is proven genuine, the AAGUID compared against Allow is the value the AUTHENTICATOR self-reported in its attested credential data — trusted because the chain establishes it came from a legitimate vendor batch, not because the certificate cryptographically attests to that exact value.
An empty Allow list disables the check entirely (no comparison is meaningful without a list to check against).
type BeginLoginOut ¶
type BeginLoginOut struct {
SessionID string
Options *protocol.CredentialAssertion
}
BeginLoginOut is the response shape for the assertion ceremony begin step.
type BeginRegistrationOut ¶
type BeginRegistrationOut struct {
SessionID string
Options *protocol.CredentialCreation
}
BeginRegistrationOut is the response shape for the registration ceremony begin step.
type Config ¶
type Config struct {
// RPID is the Relying Party identifier (effectively the apex domain).
RPID string
// RPDisplayName is shown by browsers and authenticators.
RPDisplayName string
// Origins lists the exact https origins the RP serves from. All
// origins MUST be https except for "http://localhost*" which is
// allowed by the WebAuthn spec.
Origins []string
// AAGUIDAllowList, if non-empty, restricts accepted authenticators by
// AAGUID (see AAGUIDPolicy.Enforce, applied in FinishRegistration).
// FedRAMP / NIST 800-63B AAL3 populate this with FIDO-certified
// authenticator AAGUIDs. R9-1: an allow-list rejects "none"/self
// attestation outright (the AAGUID has no attestation vouching for
// it in either shape). R10-2: for every OTHER (x5c-carrying)
// attestation type, AAGUIDPolicy.Enforce independently re-verifies
// the attestation leaf certificate against AttestationRoots — which
// is REQUIRED whenever AAGUIDAllowList is non-empty — and rejects a
// self-signed leaf unconditionally. Neither go-webauthn's own
// packed-attestation handler nor its MetadataProvider/MDS path can
// be relied on for this: the former never validates the x5c chain
// at all, and the latter (protocol/metadata.go's ValidateMetadata)
// skips chain validation entirely whenever the leaf is self-signed —
// exactly the certificate an attacker mints trivially. See
// AAGUIDPolicy's doc for the full authenticator-binding guarantee
// this now provides.
AAGUIDAllowList [][]byte
// AttestationRoots is the trust-anchor pool AAGUIDPolicy.Enforce
// verifies an x5c-carrying attestation leaf certificate against
// (R10-2). REQUIRED whenever AAGUIDAllowList is non-empty — NewServer
// returns ErrInvalidConfig otherwise, since an allow-list with no
// trust anchor to verify attestation chains against cannot
// distinguish a genuinely vendor-attested authenticator from a
// trivially self-signed forgery. Populate from your accepted FIDO
// vendor attestation root certificates (e.g. via
// security.webauthn.attestation_roots_path in a generated server).
AttestationRoots *x509.CertPool
// AttestationPreference: "none" | "indirect" | "direct" | "enterprise".
// FedRAMP Moderate+ should use "direct" or "enterprise"; default "direct".
// Note this is only a REQUEST to the client/authenticator — nothing in
// this package or go-webauthn itself compares the returned attestation
// statement's actual format against the preference that was requested,
// so a response can still come back with weaker attestation (e.g.
// "none") than requested. AAGUIDPolicy.Enforce's R9-1 fail-closed check
// is what actually gates on the returned attestation, not this field.
AttestationPreference string
// UserVerification: "required" | "preferred" | "discouraged".
// FedRAMP AAL3 requires "required".
UserVerification string
// RequireResidentKey makes registered credentials discoverable
// (passkey-style); enable for usernameless flows.
RequireResidentKey bool
// MetadataProvider, when set, is passed through to the underlying
// webauthn.Config.MDS, enabling go-webauthn's own ValidateMetadata
// path (FIDO MDS status/trust-anchor lookups keyed by AAGUID) for
// attestation types that carry a certificate chain. Optional, and —
// as of R10-2 — NOT what closes the residual chain-verification gap
// AAGUIDPolicy.Enforce is otherwise vulnerable to: AttestationRoots
// (required whenever AAGUIDAllowList is non-empty) is what does
// that, independently of whether MetadataProvider is configured.
//
// Two separate reasons MetadataProvider is not a substitute:
//
// 1. go-webauthn's ValidateMetadata (protocol/metadata.go) skips
// x5c chain validation entirely whenever the attestation leaf
// certificate is SELF-SIGNED (Subject.CommonName ==
// Issuer.CommonName) — precisely the shape an attacker mints
// trivially, satisfying the packed §8.2.1 certificate-shape
// rules without holding any real vendor attestation key. This
// holds true even with a maximally strict, fully-populated MDS
// provider.
// 2. go-webauthn's metadata.Provider interface fails OPEN by
// default: protocol/metadata.go only errors on a missing MDS
// entry for an AAGUID when the provider's GetValidateEntry(ctx)
// method returns true. A permissive provider (one that returns
// false, or a stub/test double that doesn't implement real MDS
// lookups) validates NOTHING — GetEntry can return (nil, nil)
// and registration proceeds regardless of whether any genuine
// metadata backs the claimed AAGUID.
MetadataProvider metadata.Provider
Sessions SessionStore
Credentials CredentialStore
}
Config configures a Server.
type CredentialStore ¶
type CredentialStore interface {
Add(ctx context.Context, tenantID, userID string, c webauthn.Credential) error
GetByUser(ctx context.Context, tenantID, userID string) ([]webauthn.Credential, error)
GetByID(ctx context.Context, tenantID string, credentialID []byte) (userID string, c webauthn.Credential, err error)
UpdateSignCount(ctx context.Context, tenantID, userID string, credentialID []byte, count uint32) error
Delete(ctx context.Context, tenantID, userID string, credentialID []byte) error
}
CredentialStore persists registered authenticators. Implementations MUST be safe for concurrent use. Every method takes a tenantID as the first key component; cross-tenant reads and writes MUST be impossible (REQ-0013 — mirrors devnw.dev/oidc internal/db/webauthn_credentials.go). This isolation MUST hold structurally — an implementation must not derive the tenant/user scope by concatenating the two into a single delimited string and re-splitting it, since neither tenantID nor userID is guaranteed delimiter-free (R11-2: a naive "tenant+'|'+user" key let (tenant="acme|x", user="bob") and (tenant="acme", user="x|bob") collapse onto the same row). NewMemoryCredentialStore's memCredStore satisfies this with a structured (tenant, user) map key rather than a composed string.
Add MUST be an upsert: a call with a credential ID that already has a stored row (scoped by tenantID+userID) MUST replace that row in place (MERGE-on-conflict semantics), never append a duplicate. Server calls Add again on every successful login to persist an advanced SignCount and refreshed Flags (see webauthnx.go FinishLogin, L-42) — an implementation that does not upsert accumulates one duplicate row per login for the same credential ID, which both grows the store unboundedly and makes GetByUser return an ever-larger duplicate set (R9-3). NewMemoryCredentialStore's memCredStore.Add satisfies this by scanning for a matching ID before appending.
Credential IDs MUST be unique per tenant (R10-1): Add MUST reject an ID already bound to a DIFFERENT userID within the same tenant, rather than silently creating a second row under a second user for the same credential ID. The credential ID is fully attacker/client-chosen (§5.4.3), so without this guarantee an attacker can register their own key under a victim's existing credential ID; GetByID (below) then has two candidate rows to resolve for the same ID, and which one it returns determines whose account a forged assertion signed by the attacker's key is reported as authenticating — see FinishLogin's discoverable-login resolution in webauthnx.go, which depends on GetByID returning a single, unambiguous owner per credential ID. NewMemoryCredentialStore's memCredStore.Add satisfies this by scanning every OTHER user's rows in the tenant before appending a new row and returning ErrCredentialAlreadyRegistered on a cross-user collision.
func NewMemoryCredentialStore ¶
func NewMemoryCredentialStore() CredentialStore
NewMemoryCredentialStore returns a non-persistent store suitable for tests and local dev. Production deployments MUST use a durable backend (the docs/plans/2026-05-28-webauthn-passkey.md Task 4 SQLite store is the documented follow-up).
type FinishLoginEvent ¶
type FinishLoginEvent struct {
TenantID string
// Subject is the authenticated user (the owner that began the
// ceremony and the credential's user_id).
Subject string
// Credential is the credential that verified the assertion.
// SignCount has already been advanced atomically by the store.
Credential *webauthn.Credential
}
FinishLoginEvent captures the outcome of a verified WebAuthn login. The post-ceremony OnLoginVerified hook receives it so consumers can mint downstream artifacts (e.g. OIDC authorization codes, step-up JWTs) without apic taking on OAuth-specific responsibilities.
type FinishLoginOut ¶
type FinishLoginOut struct {
UserID []byte
CredentialID []byte
SignCount uint32
// Credential is the credential that verified the assertion. SignCount
// has already been advanced atomically by the store. Exposed so the
// generated handler can hand it to the OnLoginVerifiedHook (GENWA-W2)
// without re-loading from the store.
Credential *webauthn.Credential
}
FinishLoginOut is the response shape for the assertion ceremony complete step.
type FinishRegistrationOut ¶
type FinishRegistrationOut struct {
CredentialID []byte
}
FinishRegistrationOut is the response shape for the registration ceremony complete step.
type OnLoginVerifiedHook ¶
type OnLoginVerifiedHook func(w http.ResponseWriter, r *http.Request, ev FinishLoginEvent) (body []byte, contentType string, err error)
OnLoginVerifiedHook is the post-ceremony hook signature. It runs AFTER FinishLogin verifies the assertion and updates sign-count. The returned bytes (when non-nil) become the response body — the generated handler writes them verbatim with the supplied Content- Type. Returning a non-nil error makes the handler emit the generic 403 envelope; the wrapped reason flows into obsx.LogAudit for operators but never to the client.
The hook receives r so consumers can read query params (e.g. OIDC state/redirect_uri/code_challenge), and w so they can set additional headers (Set-Cookie, Cache-Control: no-store, etc.). Returning (nil, nil) tells the handler to fall through to the generated default response.
type RecordingResponseWriter ¶
type RecordingResponseWriter struct {
http.ResponseWriter
// contains filtered or unexported fields
}
RecordingResponseWriter wraps an http.ResponseWriter so a caller can later ask whether the wrapped writer committed any response bytes. Used by the generated webauthn_authenticate_complete handler to suppress a fallback WriteForbidden call when the post-ceremony hook already wrote a response AND THEN returned an error.
The wrapper records the call BEFORE forwarding to the inner writer so a panic in the inner WriteHeader/Write still leaves Wrote() == true; the handler must assume the wire has been touched.
func WrapRecording ¶
func WrapRecording(w http.ResponseWriter) *RecordingResponseWriter
WrapRecording returns a *RecordingResponseWriter over w. Always returns a non-nil wrapper; callers may use its Wrote method unconditionally after the wrapped handler returns.
func (*RecordingResponseWriter) Write ¶
func (r *RecordingResponseWriter) Write(b []byte) (int, error)
Write records the call and forwards to the wrapped writer. The byte count and error are returned verbatim from the inner Write so callers see the underlying writer's contract.
func (*RecordingResponseWriter) WriteHeader ¶
func (r *RecordingResponseWriter) WriteHeader(status int)
WriteHeader records the call and forwards to the wrapped writer.
func (*RecordingResponseWriter) Wrote ¶
func (r *RecordingResponseWriter) Wrote() bool
Wrote reports whether WriteHeader or Write have been called on this wrapper. Once true, the underlying http.ResponseWriter has either committed a status line or buffered body bytes (or both) and any subsequent WriteHeader call will trigger net/http's "superfluous WriteHeader" warning.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is the public façade for ceremonies.
func (*Server) BeginLogin ¶
func (s *Server) BeginLogin(ctx context.Context, tenantID, owner string, userID []byte) (*BeginLoginOut, error)
BeginLogin loads the user's known credentials (allowCredentials) and starts an assertion ceremony.
tenantID scopes both the credential lookup and the persisted session. owner is the authenticated subject (when known) on whose behalf the ceremony is started. It is recorded with the session for symmetry with the registration flow but, per Task 1.2's minimum-correct scope, FinishLogin does NOT currently enforce a mismatch — discoverable- credential login may be initiated without a known caller subject and resolves the user from the assertion. Enforcement for known-user login is a defense-in-depth future enhancement.
UserVerification is set explicitly per-ceremony via webauthn.WithUserVerification rather than relying on the global webauthn.Config.AuthenticatorSelection default propagation. This makes the policy contract explicit and prevents a silent downgrade if the upstream library changes its default-copy behavior. Mirrors devnw.dev/oidc webauthn.go:365 (GENWA-R4).
func (*Server) BeginRegistration ¶
func (s *Server) BeginRegistration(ctx context.Context, tenantID, owner string, u User) (*BeginRegistrationOut, error)
BeginRegistration starts the registration ceremony and persists the session for FinishRegistration.
tenantID scopes the ceremony to a single tenant; the matching FinishRegistration call MUST use the same tenantID or the session lookup will return ErrSessionNotFound (REQ-0013).
owner is the authenticated subject (e.g. the JWT `sub` claim) on whose behalf the ceremony is started; it is recorded with the session so FinishRegistration can enforce ErrOwnerMismatch (GAP-0057 ceremony-splice defense). It is intentionally distinct from u.ID — the latter is the opaque WebAuthn user handle stored by the relying party.
UserVerification is set explicitly per-ceremony via webauthn.WithAuthenticatorSelection rather than relying on the global webauthn.Config.AuthenticatorSelection default propagation. This makes the policy contract explicit and prevents a silent downgrade if the upstream library changes its default-copy behavior. Mirrors devnw.dev/oidc webauthn.go:168-170 (GENWA-R4).
Existing credentials for (tenantID, u.ID) are fetched and passed via webauthn.WithExclusions so the ceremony's excludeCredentials field is populated (go-webauthn does NOT derive this automatically from User.WebAuthnCredentials() — the caller must supply it explicitly). Without this, a platform authenticator (Windows Hello, Touch ID, Chrome/Password-Manager passkeys) has no signal that a resident credential already exists for this (RP ID, user handle) pair, and commonly overwrites it in place when a new one is created — the server then holds a stale row for the old credential ID alongside a new one, which surfaces to users as "registering a passkey doesn't persist." A GetByUser error aborts registration (fail-closed): silently proceeding with an empty exclude list on a broken store risks exactly the silent-overwrite failure mode this exists to prevent.
func (*Server) FinishLogin ¶
func (s *Server) FinishLogin(ctx context.Context, tenantID, owner, sessionID string, body []byte) (*FinishLoginOut, error)
FinishLogin verifies the assertion, increments sign count, and returns the authenticated user ID.
tenantID MUST match the value supplied to the matching BeginLogin call; otherwise Sessions.Take returns ErrSessionNotFound.
owner is the authenticated subject of the caller completing the ceremony. For KNOWN-USER login (the BeginLogin path that supplied a user handle, so sess.UserID is non-empty) it MUST equal the owner recorded by BeginLogin; any mismatch returns ErrOwnerMismatch and is checked BEFORE the assertion is validated (SEC-0029), mirroring FinishRegistration. The DISCOVERABLE-credential path (BeginLogin called with no user handle) is exempt: it may be initiated without a known caller subject (owner == "") and resolves the user from the assertion, so no owner is available to compare against.
func (*Server) FinishRegistration ¶
func (s *Server) FinishRegistration(ctx context.Context, tenantID, owner, sessionID string, body []byte) (*FinishRegistrationOut, error)
FinishRegistration verifies the attestation, applies the AAGUID policy, stores the credential, and returns the new credential ID.
tenantID MUST match the value supplied to the matching BeginRegistration call; otherwise Sessions.Take returns ErrSessionNotFound (cross-tenant lookups are indistinguishable from missing rows by design).
owner is the authenticated subject of the caller completing the ceremony. It MUST equal the owner recorded by BeginRegistration; any mismatch returns ErrOwnerMismatch and is checked BEFORE the attestation is parsed by go-webauthn. This closes the GAP-0057 ceremony-splice attack — an attacker who obtains a sessionID for another user's in-flight registration cannot bind a passkey to that other account. Reference: devnw.dev/oidc webauthn.go:253.
type SessionStore ¶
type SessionStore interface {
Put(ctx context.Context, tenantID string, sd webauthn.SessionData, owner string, ttl time.Duration) (string, error)
Take(ctx context.Context, tenantID, id string) (sd webauthn.SessionData, owner string, err error)
}
SessionStore persists short-lived ceremony state. Implementations MUST treat Take() as one-shot — re-consuming the same session id MUST return ErrSessionNotFound (or ErrSessionConsumed). This protects against replay of the registration/authentication blob.
Every method takes a tenantID; cross-tenant Take MUST be indistinguishable from a missing row (ErrSessionNotFound). Put also records an owner string so Task 1.2 (GAP-0057) can enforce that the caller who completes the ceremony is the same subject that started it. Take returns the owner so callers can match it against the authenticated subject without a second round-trip.
func NewMemorySessionStore ¶
func NewMemorySessionStore() SessionStore
NewMemorySessionStore returns an in-process SessionStore. Suitable only for single-instance deployments; horizontally-scaled deployments MUST plug in a Redis-backed store (out of scope here).
APPSEC-08: spawns a background sweeper that deletes expired rows every sweepInterval. Without this, an attacker who triggers many Begin ceremonies without ever completing them would pin memory forever. Call StopSweeper() in tests for deterministic shutdown.
type StepUpClaims ¶
type StepUpClaims struct {
// Subject is the principal the step-up assertion is bound to.
// RFC 7519 §4.1.2. Required.
Subject string
// AMR is the RFC 8176 Authentication Methods References list.
// Apic WebAuthn FinishLogin mints AMR:["webauthn"]. Required —
// a step-up token with no AMR conveys nothing the regular access
// token doesn't already carry.
AMR []string
// Issuer ("iss") is optional. Resource servers configured with
// JWTVerifyOptions.RequireIssuer will reject tokens that omit it,
// so production deployments SHOULD set this.
Issuer string
// Audience ("aud") is optional. Same RequireAudience caveat as
// Issuer. Apic callers typically pass their own service URL so
// step-up tokens minted for service A cannot be replayed against
// service B.
Audience string
// TTL bounds the lifetime of the assertion. Defaults to
// defaultStepUpTTL when zero or negative; the helper does not
// permit a token with TTL > 1 hour by accident — callers that
// genuinely need a long-lived step-up should issue a regular
// JWT, not a step-up one.
TTL time.Duration
}
StepUpClaims captures the minimum claim set RFC 8176 §1 expects for an authentication-method-reference assertion. AMR is the list of auth methods the principal completed during this session; typical values are registered in the IANA "JSON Web Token Claims" registry — "webauthn", "pwd", "otp", "mfa", "hwk", "sc". When a downstream resource gates on "webauthn", the receiving server checks the claim with RequireAMR.