System threat model#
Scope: the code in this repository (server, worker, CLI, Go SDKs, examples) as
deployed per docs/operations/installation.md.
Method: assets, trust boundaries (docs/plan.md §4, §25.2) and STRIDE per component.
Each mitigation names the code that implements it and the test that exercises it;
"residual" lists what is not mitigated. Test IDs refer to docs/plan.md §33.2 and
appear in test names or comments under tests/ and internal/.
This document reflects code review, not an independent security assessment (SEC-018 has not been done).
Assets#
| Asset | Where | Why it matters |
|---|---|---|
| Root, pepper and signing-wrap keys | Operator-provided files, environment, secret stores or KMS-wrapped files; unwrapped in process memory | Decrypt every stored secret; wrap signing keys |
| Signing private keys | signing_keys (encrypted) | Forge any access/ID token for offline verifiers |
| Password verifiers, TOTP seeds, recovery-code verifiers, passkey public keys | credentials, recovery_codes, webauthn_credentials | Account takeover |
| Bearer artifacts: refresh tokens, codes, session cookies, opaque/support tokens, API keys, PATs, client secrets, invitation/setup/reset/OTP codes, handoff codes, download tokens | Digests or HMAC verifiers in the database; plaintext only in transit and in the encrypted notification payloads | Impersonate a user, client or service |
| Session, grant, membership and role state | sessions, authorization_grants, memberships, role_assignments | Decides who may get tokens and what they may do |
| Tenant boundaries | realm_id / organization_id everywhere | Cross-tenant disclosure |
| Personal data | data-inventory.md | Privacy |
| Audit evidence | audit_events, audit_batches; imported history in imported_audit_events (not evidence of this server) | Accountability, incident response |
| Configuration (clients, redirects, policies, webhooks) | Database, config file | Redirect hijack, privilege changes, exfiltration endpoints |
| Lifecycle workflow definitions and plugin packages; trusted plugin publisher keys | workflows, workflow_revisions, plugins, plugin_versions; extensions.plugin_publishers in the config file | Automated role grants, removals and suspensions in the publisher's name; configuration installed as a reviewed unit; a publisher key decides which packages can be installed |
| Migration exports and converted import documents | Operator machines (identity-cli migrate-from input, --write-documents output), POST /migration-plans request bodies (memory only) | Password verifiers, TOTP seeds and client secrets of the source system |
Actors and trust boundaries#
| Boundary | Inside | Outside | Enforcement |
|---|---|---|---|
| Internet → public listener | Server | Browsers, clients, attackers | TLS (proxy or server.tls_*), body/header/time limits, load shedding, rate budgets |
| Public metadata | — | Anyone | Discovery, JWKS: read-only, no secrets |
| Public protocol initiation | — | Anyone | Client and redirect validation before any state (oauth.validateInteractive) |
| Transaction-bound | Transaction holder | Others | Continuation secret (header) or cookie + CSRF + Origin (authentication.lockTx) |
| User self-service | User | Other users, machines | Account-audience JWT + live session check; a.self rejects machine tokens |
| Tenant administration | Organization administrators | Other organizations | org.* permissions in the organization scope (orgGuard); 404 for organizations without any permission |
| Provisioning (SCIM) | Provisioning connectors | Other organizations, accounts the connector did not provision | Per-connector bearer token (pa_scim_, digest only), bound to one organization, resource types and a role ceiling (provisioning.Authenticate); trust class provisioning |
| SAML counterparties | Registered SAML applications (SPs) and SAML connections (IdPs) | Other XML senders and receivers | Pinned certificates and entity IDs, exactly registered ACS URLs, single-use correlation bound to the browser, a parser that refuses DTDs and comments, goxmldsig verification; metadata uploaded, never fetched (saml.md) |
| Directories → accounts | LDAP/AD and outbound SCIM connectors of a realm or one organization | Other organizations, protected and privileged accounts, accounts another source owns | Encrypted write-only credentials, verified TLS, egress checks at connection time, failover only on transport errors, ceilings bounded by the creator's, deletion threshold (directory.md) |
| Realm administration | Realm administrators | Organization admins, users | Admin-audience JWT from an MFA session (a.admin, requireMFASession), realm.* permissions |
| Plugin publisher → realm | Packages signed by an operator-trusted key, reviewed and installed by realm administrators | Unsigned or unknown packages, other publishers | Ed25519 signature over the manifest with the payload digest, extensions.plugin_publishers, name ownership, review and declared-permission ceilings (plugins.md) |
| Workflow runs → realm state | The accountable publisher's authority, re-checked per effect | Workflow subjects, triggering events | Bounded step vocabulary, delegation ceiling at publication and run time (workflows.md) |
| Support | Support operators | Everyone else | Dedicated permissions, recent MFA, opaque tokens, resource opt-in (impersonation.md) |
| Resource servers | Registered introspectors | Other clients | Client authentication + per-audience introspection registration |
| Operator channel | identity-cli operator commands | API users | Local access to the database and keys; no network endpoint |
Internal listener (and the optional worker listener workers.metrics_listen) | Monitoring | Everyone else | Network placement; optional bearer (server.metrics_token) for /metrics and /status |
| Server → outbound | Worker, federation, pre-action hooks, challenge providers, LDAP directories (directory.egress_allowlist), outbound SCIM | Internet and private networks | Egress policy: address checks at connect time, no redirects, size/time limits; optional residency allowlist (privacy.residency) |
| Server → telemetry | OTLP exporter (observability.otlp_endpoint) | Collector | Operator-configured endpoint; span metadata only |
Host → realm | Public origin and verified custom domains | Other host names | server.host_policy: strict; issuer never derived from Host |
| Application role → database | Server, worker, CLI | Database superuser, schema owner | Append-only triggers; optional runtime role with data privileges only and audit pruning only through proudauth_audit_prune (storage.runtime_role, OPS-013, AUD-007); application cannot prevent a superuser or owner from editing data |
| Security-event exchange (AUD-016, ADV-007) | Receiver machine clients with realm.ssf_streams.manage; registered transmitters | Other clients, unregistered senders | Admin-audience client-credentials tokens and per-client streams; per-transmitter push secret, signature, issuer, audience, age and replay checks, explicit action policy (cross-system-trust.md) |
| Registry clients → registry token endpoint (ADV-010) | Holders of credentials minted for the registry's resource; anonymous callers for anonymous-pull rules | Everyone else | HTTP Basic credential check, per-action permission and scope checks, short-lived tokens for the registry audience (cross-system-trust.md) |
| RP pages → check-session iframe (OIDC-014) | Origins registered for the client | Other origins | Origin check before answering; state recomputed with the sender's origin (cross-system-trust.md) |
The application-provided login UI is outside the trust boundary for decisions: it can only submit proofs; the server decides completion (AUT-004).
STRIDE by component#
1. Public protocol endpoints#
| Threat | Mitigation (code) | Tests | Residual |
|---|---|---|---|
| S: code interception or substitution | PKCE S256 mandatory; code bound to client, exact redirect, transaction; single use; replay revokes the grant (oauth/token.go codeGrant, session/grants.go ConsumeCodeTx) | TEST-028 (TestAuthorizationCodeBinding) | — |
| S: open redirect / redirect tricks | Exact match; registration rejects wildcards, userinfo, fragments, traversal, encoded separators (application/redirects.go) | TEST-029; fuzz FuzzMatchRedirect, FuzzValidateRedirectRegistration | Native loopback accepts any port by design (RFC 8252) |
| S: client impersonation | One auth method per request; secrets as digests; private_key_jwt with asymmetric keys from the client's registered set, every aud value naming this server (at most two), ≤ 5 min, single-use jti; tls_client_auth against a realm client trust anchor and exactly one registered subject or SAN value, self_signed_tls_client_auth against a registered certificate (application/clientauth.go, internal/certauth) | TestIntrospectionExchangeAndMachines (wrong secret), TestPrivateKeyJWT, TestMutualTLS, CI | Certificates behind a proxy are trusted from server.trusted_proxies only, and only if the proxy strips the header from client requests; a self-signed client certificate is accepted without checking the registered key's expiry; revocation is checked only against uploaded CRLs and only for the leaf |
| S: issuer confusion via Host header; serving a realm on an unverified host (DEP-014) | Issuer from configuration only (configuration.Issuer), also on custom domains; custom domains need DNS TXT proof of control and an MFA session; with server.host_policy: strict only the public origin's host and verified custom domains are served, each custom domain only for its own realm's paths, others get 421 (httpapi/app.go); cookies are host-only; cookie-authenticated calls accept only verified custom origins of that realm, on the default port or the public origin's port (realm/domains.go, httpapi/protocol.go originAllowed); verification succeeds only for the pending challenge that was checked, so a removal during the DNS lookup wins | TestDiscoveryAccuracy, TestCustomDomains | The default host_policy: any serves every Host. Only a verified domain reserves its host across realms (migration 0043); the 409 ("the host is already in use") at verification still tells a realm administrator that another realm verified that host. Passkeys work on a custom host only when it is under the realm's effective RP ID. The port rule of the origin check and the removal race have no test. TLS and routing for custom hosts are the operator's |
| T: parameter pollution | Repeated single-valued parameters rejected (ParseAuthorize, Token) | — | — |
| T: refresh token theft | Rotation with reuse detection, family compromise, session revocation (session.RotateRefresh); fapi2 clients keep one refresh token, but they are confidential and their tokens sender-constrained, so a stolen refresh token alone is useless | TEST-030, TEST-031, TEST-032, TestFAPI2Profile | Refresh tokens not sender-bound (DPoP binds access tokens only); a fapi2 refresh token is never flagged as reused |
| I: introspection disclosure to other services | Only registered introspectors of the token's audience; otherwise {"active": false} (oauth/introspect.go) | TEST-037 | — |
| I: token confusion | Typed tokens (at+jwt, JWT, logout+jwt), token_kind, single audience, prefix routing for opaque secrets | TEST-001, TEST-002, TestIntrospectionExchangeAndMachines (UserInfo audience) | — |
| E: scope escalation | Scopes only from the client's resource grants; refresh can only narrow; exchange limited by explicit transitions (ResolveScopes, exchangeGrant); scopes with a step-up policy (step_up_acr, max_auth_age_seconds, or sensitivity: privileged) raise the transaction's assurance and are left out at issuance and refresh when the grant's acr or auth_time no longer meets them (application.StepUp, oauth.applyStepUp, AUT-010); token exchange checks them against the subject token's acr and auth_time (invalid_scope for a requested scope; the default ceiling leaves them out), and API keys and personal access tokens never carry them (oauth.ScopesPermitted); token exchange also applies the organization's allowed_scopes and scope required_permissions, and refuses an exchange with no scope left instead of issuing a scope-less token (oauth/exchange.go) | TEST-038, refresh scope test, TestScopeStepUp (organization ceiling in exchange) | The automation-credential checks and required_permissions in token exchange have no regression test |
| R: delegation hides the real actor (AUD-005, MCH-017) | The token.exchanged audit event names the service principal the exchanging client acts as (the client only when it has none) as actor, the user as subject, and keeps the whole act chain, including the subject token's earlier actors, as metadata.actor_chain (outermost first, agents marked, at most 16 entries, actor_chain_truncated beyond); authorization.decision events about an exchanged, agent or support token keep metadata.actor and the same actor_chain, and subject_agent for an agent's own token (oauth/exchange.go, httpapi/decision.go, token.Actor.AuditChain) | TestExchangeActorPreservation, TestActorAuditChain | — |
| S/E: development client or token trusted by production (APP-015) | A client's environment is fixed at registration and must match its project's; token exchange refuses subject tokens issued to a client of another environment; production clients cannot register loopback redirects or http origins (application.UpdateClient, checkProjectEnvironment, oauth.exchangeGrant) | TestEnvironmentSeparation | Resource servers that verify tokens offline see no environment claim; environments share the realm's issuer and signing keys, so separate realms or deployments are the stronger boundary |
| I/E: claim mappings leak attributes or forge claims (APP-011) | Mappings read only user.given_name, family_name, display_name, locale, zoneinfo and profile attributes the realm marked claimable; attribute values are released only when the grant includes the profile scope; the profile schema refuses attributes that are both claimable and user_editable, and such attributes stored earlier are never released; no expressions; reserved claims, and in ID tokens standard user claims and claims released by scope, are never set (application/claimmap.go, oauth/mint.go) | TestClaimMappings (e2e and unit); fuzz FuzzClaimMappings (non-claimable attributes never released) | user.* values are edited by users through PATCH /me and can be released under any claim name. Access-token mappings may use standard user claim names (not email*, phone_number* or address) only with $attr, but a mapping's default is still a static value under that name, and mappings registered before the rule are not re-checked at issuance |
| S: open-redirect chain through the identity provider's own endpoints (APP-004) | redirect_uris and post_logout_redirect_uris on the host of server.public_origin are refused at registration and update (application.checkOwnOrigin) | TestRedirectRegistrationRules | The host is compared as written, including an explicit port, and verified realm custom domains are not checked |
| E: password grant / implicit flow | Not implemented; rejected at registration and endpoint | TestAuthorizationCodeBinding, TestDiscoveryAccuracy, smoke test | — |
| D: token endpoint flooding; one tenant starving others | Per-client budget (token_per_client), per-organization budget (organization_per_window) and per-organization password budget (login_per_organization), load shedding; an unavailable limiter refuses issuance (503 temporarily_unavailable) instead of failing open (oauth/token.go). Workers claim at most workers.tenant_batch_share notifications per organization and deliveries per webhook subscription per batch and run at most workers.max_jobs_per_organization jobs per organization (TEN-018) | TestPasswordlessAndAbuseBudgets (per-organization budget), TestTenantFairness | No per-IP budget at the token endpoint; password budgets per organization count only transactions that name an organization |
| S: sender-constrained token replay (TOK-019) | DPoP proof verified at the token endpoint (cnf.jkt); client certificates bound as cnf.x5t#S256; the IdP's own APIs and UserInfo require the matching DPoP proof or the same certificate (httpapi/auth.go checkSenderConstraint); device-code and token-exchange grants bind to the request's own proof or certificate; opaque tokens keep cnf, introspection returns it | TEST-067 (TestDPoPBoundTokens, TestMutualTLS) | Resource servers must enforce cnf themselves: the SDK verifiers do not; refresh tokens are not bound; DPoP has no nonces |
| S/I: local logout leaves the upstream session (FED-013) | /oidc/logout reports upstream_logout honestly (not_requested unless the client asked); with upstream_logout=true the browser is sent to the provider's end-session endpoint with a sealed, realm-bound, single-use 15-minute state, and the return only redirects to the client's registered URI; post_logout_redirect_uri is validated before anything is revoked (oauth/discovery.go) | TestFederationAdapters (forged and replayed state), TestBrowserFlowCSRFSSOAndLogout | returned means the browser came back, not that the provider ended its session; no test asserts that a refused redirect leaves the session intact |
| I/S: front-channel logout page (SES-018) | Frames only the frontchannel_logout_uris of active clients that took part in the ended session; each URI must share scheme, host and port with a registered redirect URI; the page's CSP allows framing only those origins, no script, no form actions and no framing of the page itself; iss and sid only for clients that ask (httpapi/protocol.go, application/protocol_options.go) | TestFrontChannelLogout | Delivery is not confirmed and browsers that block third-party cookies often defeat it; a client relying on it alone may keep a local session after logout. The meta refresh timing, the page without a redirect and the exclusion of inactive clients have no test |
| I: response content exposed to the browser or logs (TOK-020, OIDC-013, ADV-002) | Optional encryption of ID tokens, UserInfo and JARM responses to the client's registered key (RSA-OAEP-256, ECDH-ES; AES-GCM only; RSA1_5, SHA-1 OAEP and CBC refused); JARM responses signed with the realm key, aud = client, 10-minute expiry; errors after the redirect is trusted use the same mode (oauth/jwe.go, oauth/jarm.go) | TestEncryptedResponses, TestJARM | Encryption does not replace validating signature, issuer, audience and nonce; a missing encryption key is found only at issuance, after the authorization code was consumed; JARM through the browser endpoint and PAR, and prompt=none errors in JARM mode, have no test |
| S/E: workload assertion accepted beyond its trust (MCH-011..MCH-013, ADV-004) | Trusts per client with exact issuer, explicit keys or key URL, exact audience, subject pattern (no wildcard across repositories or service accounts), claim constraints, algorithm allowlist without HMAC or none, bounded lifetime and single-use jti; signed JWS only; the assertion is never accepted as client authentication and never yields a user token; tokens never outlive the assertion; refusals audited with their reason (internal/workload, oauth/jwtbearer.go) | TestWorkloadFederation | A trust is only as narrow as its subject and constraints: a trailing * is a plain string prefix; the jti is single-use per trust, not per client (it is spent in the issuing transaction, only by an otherwise accepted request); jwt-bearer has no emergency grant control; the reserved-claim refusal has no effective test |
| D/I: workload key fetching (ADV-004, NFR-006) | Key sets obey federation egress/TLS/size/time rules, cache five minutes per URL and share one fetch per process; unknown keys and failures respect a one-minute retry cooldown. Last-good transport/status fallback remains explicit; malformed/oversized/empty responses fail after expiry | TestWorkloadFederation, TestWorkloadFetchBudgetAndFallback, TestFederationFetchBudgets, cache cancellation/invalidation/DNS deadline regressions | These are local correctness bursts, not sustained multi-process metadata capacity; stale last-good keys remain usable during the declared transport/status fallback |
| E: dynamic registration abuse (APP-016, APP-017, ADV-003) | Off by default; initial access tokens bound to non-privileged scopes (re-checked at use), limited uses and expiry; open registration always held for approval; registered clients are third-party with consent required, no offline access, no first-party status; jwks_uri, software statements and other grants refused; client metadata URLs never fetched; 20 registrations per hour per network and 500 per realm, failing closed (application/dcr.go) | TestDynamicClientRegistration, TestDynamicClientRegistrationBounds (TEST-066) | Registrant-supplied contacts are unverified (never mailed, but they count as contacts in the ownership report) and the display URLs are returned as given; open registration still lets anyone create (suspended) clients up to the budgets; only the approval activates a pending registration |
| S/E: registration access token theft (RFC 7592) | Registration access tokens (pa_rat_) are digests, one per client, rotated on every update, refused for other clients and for administrator tokens, revoked when the client is decommissioned | TestDynamicClientRegistration | A stolen token can rewrite the client's redirect URIs until the next legitimate update rotates it; tokens do not expire. A registrant PUT keeps the settings an administrator controls and is one transaction (a refused secret rotation leaves the token valid) |
| E: FAPI 2.0 client weakened (ADV-001) | fapi2 rules enforced at registration (confidential, private_key_jwt or mutual TLS, code flow only, sender-constrained tokens, https redirects, realm signing algorithm) and at use (PAR forced, assertions PS256/ES256/EdDSA, 60 s codes, 10-minute access tokens) | TestFAPI2Profile | Not certified; no conformance suite run. Organization switches are refused for clients that must use PAR (403); they push a new request instead |
| S: device-code phishing | User code shown with the client identity; consent always required; single redemption; client-bound (oauth/device.go) | TEST-068 | No user-visible device metadata beyond client name and scopes |
2. Headless authentication transactions#
| Threat | Mitigation (code) | Tests | Residual |
|---|---|---|---|
| S: hijack by transaction ID | Continuation secret required (digest compare); browser mode needs cookie + CSRF (HMAC of continuation) + allowed Origin (lockTx, proofFrom) | TEST-056 (TestBrowserFlowCSRFSSOAndLogout) | — |
| E: client-asserted completion | Server computes requirements; unknown JSON fields rejected (evaluate, strict decoder) | TEST-022 | — |
| E: MFA bypass via alternate route | Every completion path evaluates MFA; email OTP is not a second factor; recovery-code sessions are recovery-restricted (reads, factor enrollment and ending sessions only; recovery_restricted otherwise) and move the account to recovery_restricted until a TOTP or passkey is used or enrolled (MFA-010, USR-003); reset keeps MFA | TEST-022, TEST-070, TestMultiFactorAndProofs (recovery-code subtest) | A recovery-restricted session still obtains account and business-resource tokens |
| E: attacker with the first factor installs their own second factor (MFA-006) | Self-service TOTP and passkey enrollment need a session that completed MFA when the account already has a factor (requireFactorAuthority); in a sign-in, a flow challenge must be answered before any in-transaction enrollment | No test for either rule (TestAuthenticationFlowPolicy covers answering a challenge) | A recovery code still answers a pending challenge |
| E: a realm administrator lowers MFA below the operator's floor (MFA-005) | security.mfa_floor (require: all, admin_phishing_resistant, forbid_org_admin_waiver): realm policy updates and configuration imports below it are refused (realm.CheckMFAFloor, 422), and sign-in and organization administration apply it even to policies saved before it was raised | TestOperatorMFAFloor | admin_phishing_resistant is enforced at sign-in only where the realm has passkeys enabled; realm creation does not check the floor (a realm below it refuses every policy update until its MFA settings are raised) |
| I: account enumeration | Generic invalid_credentials, minimum response time and dummy hashing, identical OTP/registration/reset responses (steps.go, registration.go, recovery.go) | TEST-020 | Timing equalization is best-effort; identify itself never reveals existence |
| S: online guessing | Per-identifier, per-IP, per-client and per-subject budgets in PostgreSQL; every proof attempt (codes, magic links, TOTP, recovery codes, passkeys, challenge answers) also counts against realm-wide, per-client and per-organization budgets (REG-012, SEC-006); 5 proof failures fail the transaction; TOTP step replay control | TEST-021, TEST-023, TestVerificationBudgetPerClient | Fixed windows; the per-network proof budget counts full addresses |
| T: session fixation | New session (and cookie) after authentication; cookie rotated on step-up (complete) | — | — |
| S: passkey ceremony replay or cross-origin use | Challenge bound to realm, RP ID, user and transaction, single use, 5 min; origin and RP ID checked by go-webauthn; counter anomalies handled (internal/passkey) | TestPasskeys (TEST-024: wrong challenge, origin and RP ID at assertion, missing user verification under the phishing-resistant profile; registration origin/RP ID, replay, counters; TEST-025) | Only none attestation tested; no attestation policy |
| S: federation response forgery / mix-up | Single-use hashed state, PKCE, nonce, issuer and audience checks, asymmetric algorithms, egress policy for metadata/JWKS plus shared bounded cold/expired fetches and negative-response cooldowns (internal/federation, NFR-006) | TestFederation, TestFederationFetchBudgets | Tested only against a fake provider |
| E: account takeover through federation linking | External identities keyed by connection + issuer + subject; unverified upstream email never claims an account; existing account requires local proof of control (settlePendingLink): a password, a passkey, or an emailed code or magic link sent for that link, whose message (templates account_link_code, account_link_magic_link) names the connection and says it will be linked and whose challenge is bound to the pending link, so a sign-in code or link requested before the link was pending does not count and a linking code is no plain sign-in; an SMS code never counts (PWDLESS-006, PWDLESS-008; authentication/federation.go linkProofMatches); each connection's adapter decides whether the provider's email assertions are believed: the microsoft adapter never yields a verified email and refuses trusted_verified_email linking (federation/adapters.go, FED-002) | TestFederation (TEST-026 subtests), TestFederationAdapters, TestPasswordlessLinkPurpose, TestLinkProofRequiresLinkBoundEmailProof | trusted_verified_email linking policy trusts the upstream's email verification by design; adapter semantics are tested only against test doubles; a just-in-time account from an adapter whose email is not trusted can duplicate a local account with the same address (left unverified) |
| S/E: sign-in from a risky context (AUT-012, AUT-014, SEC-014) | Realm authentication_flow rules decide, once the account is known, to allow, require MFA or phishing-resistant MFA, challenge, review or deny, from client, organization, method, resource, network, device, recent failures, privilege, assurance and risk-hook signals; the decision is kept in the transaction and re-made when the organization or policy revision changes; a missing signal applies restrictive rules (unless skip) and never satisfies allow; every decision is audited with its signals, and review raises security.alert (authentication/flow.go) | TestAuthenticationFlowPolicy (validation, challenge by provider and emailed code, risk-hook deny, failing hook, review, require_mfa) | Not tested: require_phishing_resistant, network, failed-attempt, privilege and assurance conditions, the provider's 503. A risk hook's verdict is trusted as given; a hook that fails only makes the signal missing, so rules with on_missing_signal: skip are bypassed while hooks are down, and when one of several hooks fails the others' level is only a lower bound. failed_attempts counts failures recorded against the account (wrong passwords, failed challenges), not unknown-account attempts or second-factor failures. A deny leaves the transaction pending (the client gets no error redirect) |
| I: challenge provider sees users (SEC-014) | No provider is built in or contacted unless the operator configures one and a realm names it; verification sends only the response token and the secret, and the client address only with send_remote_ip; calls go through the delivery egress policy and residency allowlist; provider answers are verified once (no transaction retry, DB-014); an emailed code, a passkey or TOTP are alternatives, and a factor enrolled in the same transaction never answers a challenge | TestAuthenticationFlowPolicy | The provider's widget runs in the login UI and collects whatever its vendor collects in the browser; a provider outage makes provider answers fail with 503 (the emailed code stays available when enabled) |
| E: local sign-in to an SSO-mandatory organization | Organization policy sso_required (+ sso_provider_id) makes the transaction require a fed factor from that connection; no local-password alternative is offered for SSO-routed users; an organization switch (POST /me/organization-grants) holds the session to the target's sso_required (a fed factor from sso_provider_id), allowed_login_methods and session_absolute_seconds (TEN-005, TEN-012); sso_provider_id must be a realm-wide connection or the organization's own, and an organization's connection never authenticates into another organization's context: refused in a transaction for another organization, which is blocked (connection_not_allowed), and 403 when such a session selects or switches to another organization (FED-006) (authentication/transaction.go, authentication/federation.go, httpapi/selfservice.go) | TEST-027 (TestFederation, "SSO-mandatory organization has no password fallback": no local method offered, a correct password does not complete, upstream outage reported without fallback); TestOrganizationSwitch | An organization's connection can still be used without an organization context; such a session reaches only realm-level resources |
| S: magic link consumed by a link scanner or used from another browser | Secret only in the URL fragment; consumption requires the requesting transaction's continuation; single use, 10 min, 3 attempts; links for unknown addresses are never sent (authentication/magiclink.go) | TestPasswordlessAndAbuseBudgets (magic links) | Email account compromise yields a primary factor |
| S: SMS code interception / SIM swap; SMS cost abuse | SMS codes count only as a primary factor, never MFA, and never prove control for linking an external identity; country allowlist and deployment-wide daily budget, both checked before the account is looked up, so a number without an account gets the same refusal (422, or 429 with the same body and Retry-After) as one with an account (API-009, notification.SMSAdmissible); per-identifier and per-IP send budgets; gateway requests HMAC-signed and egress-restricted (notification/sms.go) | TestPasswordlessAndAbuseBudgets (SMS), TestIdentifierProbeResponses (daily SMS budget) | SMS remains phishable and interceptable; enabling login.sms_otp requires the realm policy acknowledgement login.sms_risk_accepted (realm.ValidatePolicy), which records the decision but does not reduce the risk |
| S: online password guessing against one account; I: identifier probing through the lock (API-009) | Temporary password lock after failures_before_lock failures, never blocking passkey, federated or emailed-code sign-in. Each failure counts against the identifier tried and, when it names an account, against the account: once the account's budget is spent no password of it is checked through any identifier (guessing stays bounded per account), but only an identifier's own budget answers 429, so another identifier of a locked account answers like an identifier without an account (401, then the same 429) and a lock does not reveal which identifiers share an account (authentication/steps.go Password) | TestPasswordlessAndAbuseBudgets (temporary lock; an unknown identifier's 429 matches a known one's), TestIdentifierProbeResponses (password lock across identifiers) | An attacker can deliberately lock a user's password sign-in for temporary_lock; while the account is locked, its owner signing in with the right password through another identifier gets 401 (invalid_credentials) instead of 429 until that identifier's own budget is spent |
| S: CSRF via login (login CSRF) | Browser transactions start only from the authorization endpoint with server-side state; pa_tx cookie scoped to that transaction's own API path, so concurrent transactions do not overwrite each other | TEST-056 | — |
| S: HOTP code guessing or reuse (MFA-013) | Opt-in per realm; a code is accepted only within look_ahead (at most 50) counters of the stored counter and only once: the row is locked and the counter advanced with a conditional update; resynchronization needs a session that completed MFA and two consecutive codes, moves only forward and allows 5 attempts per 15 minutes, failing closed; failed codes count as failed proofs; not offered under the phishing-resistant profile; a realm that stops allowing HOTP stops accepting it, and an HOTP it does not allow no longer counts as an enrolled factor for MFA-006, password changes or the last-factor guard (authentication/factors.go, credential/totp.go, httpapi/selfservice.go) | TestHOTP (concurrent submissions of one code succeed once, replay, look-ahead, resynchronization) | The look-ahead widens the guessing window compared with one TOTP step (10 counters by default); remembered devices, administrator reset and the resync rate limit have no test (the removal guard, enrollment from a recovery-restricted session and the MFA session resync needs are covered by TestHOTP) |
| S: X.509 sign-in with a forged or revoked certificate (PROV-016) | Certificates only from the TLS connection or a header from server.trusted_proxies; chain to an active user trust anchor, validity and client-authentication usage checked; with revocation: crl a current uploaded CRL (with a next update, replaced only by a newer one) is required and every certificate of the chain below the anchor is checked against its issuer's CRL (a certificate whose issuer's CRL the anchor does not hold is refused); a binding's sample certificate must be accepted by the anchor it names; accounts resolved only by an administrator's binding or one SAN e-mail address matching a verified address; a transaction that already names an account must get that account; failures are proof failures, audited with a reason; before identify the attempt budget is per certificate and client network; bindings are deleted with the account (internal/certauth, authentication/certificate.go) | TestCertificateSignIn, internal/certauth/certauth_test.go | No OCSP or CRL fetching, and no CRLs of intermediates (certificates below an intermediate need the issuing CA as anchor under revocation: crl); a multi_factor anchor makes the certificate count as phishing-resistant MFA on the operator's word that the key needs a PIN |
3. Self-service and tenant APIs#
| Threat | Mitigation (code) | Tests | Residual |
|---|---|---|---|
| S: stolen access token reused after logout | Every call re-checks session, user, client, JTI revocation (httpapi/auth.go authenticate) | TestRegistrationAndRecovery (old token rejected after reset) | Within the token lifetime, a stolen token works until the session is revoked |
| E: machine token on human routes | a.self rejects service principals; machine tokens cannot target account/UserInfo audiences | TEST-039 | — |
| I/T: cross-tenant reads and writes | Organization ID in the path is a selector; permission checked in that organization's scope; 404 without any permission; cursors MAC-bound to tenant and route | TEST-007, TEST-008, TEST-009, TestAuditEvidenceAndRedaction (org audit) | Authority-changing organization administration (org.roles.write, org.roles.assign, org.ownership.transfer, org.delete, org.domains.write, org.update) requires an MFA session (MFA-003, orgGuard; tested in TestDelegationCeilings) unless the realm sets mfa.org_admin_mfa_optional; other organization administration accepts single-factor account-audience tokens |
| E: incompatible roles combined through groups, ancestors or composites (AZ-018) | Incompatible roles are checked against everything the subject would hold (direct, membership-bound, group and ancestor-group roles, expanded through composites) on assignment, group grants, group membership and group moves; a group member can be added, and a group moved, only by an administrator who could assign every role of the group chain (TEN-008, TEN-013) | TestSeparationOfDuties | Members added by dynamic group rules are not checked; for service principals only direct assignments count |
| E: delegated administrator escalation | Role creation, edits and assignment checked against the actor's effective permissions including composites; realm permissions cannot enter organization roles | TEST-010, TestAgentPrincipals | Roles with requires_approval, and privileged roles for agent service principals (MCH-017), go through a two-party request (/role-assignment-requests): the approver is the authenticated caller, differs from requester and subject, and must be able to grant the role (authorization/approvals.go); the workflow is tested end to end for agents only. Organization templates apply the same rule: such a grant is a template conflict until it is approved directly |
| E: last-owner removal race | Serialized ownership checks | TEST-011 | — |
| E: shared user's global credentials changed by an org admin | Global actions require realm.* permissions | TEST-012 | — |
| E: profile edits affecting authorization | Strict schema; admin metadata not user-editable; dynamic group rules accept only attributes the realm schema marks as not user_editable, a rule that stops validating removes every rule-derived member, setting a rule needs the authority to grant the group's roles, every rule-driven membership change is audited (source: dynamic), and rule-derived memberships grant roles only while the group's recomputation is newer than security.dynamic_group_max_staleness and error-free (organization/dynamic.go, authorization/rbac.go groupClosure, TEN-014, AZ-010) | TEST-013, TestDynamicGroups | Administrators who can write an attribute decide group membership; with more dynamic groups than the worker recomputes per staleness period (up to 4900 per 5-minute run, more often while groups are older than half of dynamic_group_max_staleness), members lose their roles between runs. A rule matching more than 10000 members records an error and its members then hold nothing (untested). Removing a rule keeps its members as ordinary members (audited group.dynamic_rule.removed) |
| S: organization name takeover after a rename (TEN-011) | Former slugs become aliases that keep resolving and cannot be taken as another organization's slug or alias (organization.Resolve, slugFreeTx); email-domain routing prefers the exact verified domain over a parent with include_subdomains | TestOrganizationAliasesAndDomainRouting | An administrator who removes an alias releases the name, and completing an organization's deletion releases its slug and aliases |
| I/E: an organization's integration credential used for another tenant or integration (TEN-022) | Each secret is AES-256-GCM-encrypted with associated data naming its owner, so a ciphertext copied to another row does not decrypt: the mail-server password with the realm and organization (smtp:<realm>:<org>), upstream identity-provider client secrets with the connection ID (idp-client-secret:<id>), webhook signing secrets with the subscription ID (webhook:<id>); never returned or audited. Setting a mail server needs the privileged org.integrations.write and an MFA session; plain-text SMTP refused; connections checked against the delivery egress policy, trying each resolved address in turn (notification/tenantsmtp.go) | TestTenantSMTPCredentials | The organization's server sees every message sent in its context, including invitation and verification links. Reading (GET) the mail-server settings needs org.integrations.write but no MFA session; setting and clearing them need one. An identity-provider secret is bound to the connection, not to its organization, so moving a connection to another organization moves its secret. No re-encryption path exists for rows written without associated data |
| E/I: organization templates create integrations (TEN-017) | Applying a template that defines service principals or webhooks needs org.service_principals.write or org.integrations.write in the organization, otherwise the plan lists a conflict and nothing is applied; service principals are owned by and allowed only for that organization, receive roles in its scope with the applying administrator's authority (delegation ceilings), and get no credentials; template roles cannot carry realm.* or support.* permissions; webhook subscriptions are organization-scoped, HTTPS, and held to public addresses; their signing secrets are returned once (webhook_secrets, no-store) and never replayed by the idempotency layer; each webhook.created raises a security.alert (organization/templates.go) | TestOrganizationTemplates | Applying (not a dry run) needs an MFA session even where the realm waives MFA for org.update (TestOrgTemplateAndMailServerRemovalNeedMFA). Event types, payload form and destination reachability are checked only when applying, not in the dry run. An existing subscription with the same name but another destination or event types is a conflict, never rewritten. An existing service principal is found by its full name or, after a slug rename, by the organization's own principals whose name ends in -<entry name>, so an entry can match another principal of that organization with that suffix and grant it the template's roles. Apart from the MFA requirement, none of this has a regression test |
| E: a provisioning connector escalates or reaches beyond its organization (PROV-003..PROV-007) | The connector's role ceiling must be within its creator's delegation ceiling, and mappings and the default role within that ceiling; it sees only users and groups it provisioned (connector_external_ids); existing accounts are adopted only when their verified email is in a domain the organization verified, never protected accounts or holders of realm-scoped roles; identity attributes change only on accounts it created that belong to no other organization; active and deletion act on the organization membership only (internal/provisioning) | TestSCIMProvisioning | A leaked token acts until rotated or the connector is disabled (incidents.md); the SCIM rate limit fails open when the limiter is unavailable; adoption treats subdomains of a verified domain as verified whatever its include_subdomains setting |
| D: unbounded graphs | Group depth and role expansion limits, cycle checks | TEST-014 | — |
| T: invitation misuse | Secret digest only, rotate on resend, non-consuming inspect, invitee identity check, inviter authority re-validated at acceptance, single acceptance | TEST-015..TEST-019 | Invitations are kept forever |
| E: cached authorization decisions outlive the authority they reflect (AZ-019) | Only RBAC-only decisions carry cache_key and cache_ttl_seconds (default 30 s, at most 10 minutes): never with applicable ABAC policies, active hooks, obligations, support tokens or privileged actions, never when an applicable assignment has conditions, starts later or expires within the TTL, never for subjects with rule-derived group memberships or memberships with a validity window, and never when relationships were consulted or the realm uses an external policy engine; the key covers the token's kind, audience and acr, the subject's authorization revision, a fingerprint of the applicable assignments (direct, of the subject's groups and their ancestors, through composed roles, with every role's version), the organization's and membership's state and revision, and the policy version (httpapi/decision.go cacheHint); role edits and (un)assignments bump the holders' revisions, service principals included, and a role's permission change bumps the holders of every role composing it and the members of groups holding them | TestDecisionCacheContract (group-granted roles, another audience or assurance) | ProudAuth keeps no cache: callers cache, and a decision stays usable for its TTL after a change the key does not see. Untested: validity-window and dynamic memberships, conditional or expiring assignments, service principals |
| I/E: relationship tuples cross tenants or grant beyond intent (AZ-021..AZ-023) | Tuples live in one active organization; subjects are confined to it (active members, its groups, the organization itself, service principals allowed in it, objects of registered types); tuples map only to registered application permissions, never IdP administration; relationship evaluation runs only when RBAC does not grant, and step-up, ABAC policies, policy engines and hooks still apply after it; bounded at depth 6, 64 object reads and 1000 tuples per object, a bound denying; decisions using it are never cacheable; list-objects returns only verified objects and sets truncated when a bound left one out (authorization/relations.go) | TestRelationshipAuthorization | Any holder of realm.resource_types.write can register a type under any business resource and any holder of realm.relationships.write can write tuples for every organization of the realm, so give these permissions to the services that own the objects only; tuples of former members stay stored (inert) and are not removed with organizations (tuples naming a deleted account are); list-objects applies no step-up, policies or hooks; cycle detection in implied_by has no effective test |
| E: sharing abuse (AZ-027) | Only a holder of the type's share_permission on the object can offer; only relations the type marks shareable; recipients must be active members of the same organization; accepting re-checks the sharer's right; the sharer or the recipient can revoke; offers need recent authentication and are audited (authorization/shares.go) | TestRelationshipAuthorization | Shared access lasts until revoked or expires_at, also after the sharer loses the right to share (shares past expires_at are stored as expired by the refused acceptance or the expiry sweep, and a deleted account's shares end) |
| E: just-in-time elevation misused (AZ-024) | Eligibility created only by someone who could assign the role, never for oneself; requests need a recent MFA session and a reason; approval by someone else who could assign the role (always for privileged roles); the grant is an ordinary expiring assignment, subject to separation of duties, removed by the expiry sweep; every step audited (authorization/elevation.go) | TestJustInTimeElevation | An eligibility without requires_approval (non-privileged roles only) lets its holder activate the role at will; the expiry sweep runs every 30 s, so a role can outlive its deadline briefly (views show it expired); the sweep records role.unassigned and authorization.elevation.expired |
| E/R: access reviews that miss or misapply decisions (AZ-025) | Snapshots of up to 10000 assignments; named active reviewers, never deciding their own access; decisions recorded with reviewer, time and comment (required to revoke); closing revokes only decided items (and undecided ones when configured) whose assignment still exists, recording each outcome; evidence export audited (authorization/reviews.go) | TestAccessReviews | A reviewer may decide on a group grant that includes themselves; closing is not atomic across items (a re-run completes it); the campaign does not cover group memberships, relationship tuples or elevation eligibilities |
| R: repudiation | Audit in the same transaction for every mutation | TestAuditEvidenceAndRedaction | — |
4. Administration API#
| Threat | Mitigation (code) | Tests | Residual |
|---|---|---|---|
| S: admin token theft | Admin audience requires an MFA session not recovery-restricted; live session check; sensitive actions need recent authentication | TestImpersonationDenials (stale MFA), recent-auth checks | Admin APIs share the public listener; restrict at the proxy if needed |
| E: bootstrap takeover | Bootstrap only through the local CLI; refused after first use; setup token single-use; MFA enrollment forced (provision.BootstrapAdmin) | Fixture bootstrap | Operators with database and key access are fully trusted |
| T: idempotency replay leaking secrets or crossing resources | Responses containing secrets are never replayed; the stored request hash covers method, path and body, so a key reused for another resource is a mismatch (422 idempotency_key_reused), never a replay (API-008, httpapi/idempotency.go) | TestIdempotencyKeys (different body), TestIdempotencyConcurrentSubmissions (SEC-008: concurrent submissions under one key execute once; the others get the replay, 409 while it is in progress or 422, and a secret reaches only the first caller) | The path rule has no test |
| I: session-event stream disclosure (SES-019) | GET /session-events requires an admin-audience token with realm.session_events.read, is realm-scoped, and carries identifiers and event types, not contact data; each long poll (wait at most 25 s) is a separately authenticated request (httpapi/stream.go) | TestSessionEventStream (account token refused, no contact data, gap-free cursor) | A waiting long poll re-checks the session and realm.session_events.read about every 2 seconds and before releasing events, so a revoked consumer gets 401 or 403 within about 2.5 s (TestStreamOutlivingAuthorization, TEST-062); for tokens without a session only the permission is re-checked, not the token or client state. History is kept 7 days; a cursor gets 410 cursor_expired only when events after it were pruned (event_stream_horizon, TestStreamCursorAfterPruning) |
| I/E: exports leaking other tenants or users; forged job rows (TEST-063) | Jobs bound to requester; the worker re-checks the requester's permission for every job type before it runs, and a personal export only for the requester's own account, so a queue row naming an arbitrary requester, subject or tenant dead-letters; download tokens single-use, 5 min, requester-bound, recent auth, issued and redeemed only while the requester holds the export permission (a refused redemption does not spend the token); inactive accounts and disabled service principals hold no permissions, so their jobs dead-letter; organization.delete jobs run only while the requester still holds org.delete; the realm data export (realm.data.export, privileged) leaves out credentials, secrets and live tokens by explicit column lists | TEST-069, TestJobAuthorityRechecked, TestForgedJobMetadata, TestRealmDataExport, TestInactiveSubjectsHoldNoPermissions, TestOrganizationDeletionRequesterRechecked | User-import records, including password verifiers and TOTP seeds, are encrypted job input, not returned by the API, erased at a terminal state. The realm data export holds every account's profile and identifiers |
| E/S: user import takes over or elevates accounts (MIG-004, MIG-005) | Imports never merge by identifier: an email or username that belongs to an existing account, or repeats within the batch, is a conflict; a source ID already imported is never updated; roles come only from an explicit name translation, unknown names fail the record, owner roles need allow_owner_roles, and every role must pass the requester's delegation ceiling, re-checked by the worker with the requester's current authority; deleted accounts are not recreated (worker/exports.go importUsers) | TestImportAuthorityAndHashBounds, TestMigrationPlanningAndReconciliation, TestImportFactorsAndDeletionContinuity | An administrator can still attach a source ID to an existing account (PUT /users/{user}/external-ids/{source}); an imported account is active unless its record says suspended (a locked source account has no import equivalent); email verification is kept when the requester holds the verification override and gives a reason, so a wrong source flag yields verified addresses |
| I/S: imported TOTP seeds (MIG-007) | Seeds are accepted only with import_totp, which needs realm.users.credentials.manage and an MFA session and is re-checked by the worker; they travel as encrypted job input that is never returned and is erased at a terminal state, are re-encrypted under the deployment key bound to the credential ID, and are audited (mfa.totp.imported) without the seed | TestImportFactorsAndDeletionContinuity | The importing administrator and whoever handled the export file know every seed, and seeds already exposed at the source stay exposed; the source keeps accepting them until it is shut down. Imported factors start without recovery codes |
| D/S: imported bcrypt and PBKDF2 verifiers as a CPU sink or a weak verifier (MIG-006, CRED-004) | Imports accept bcrypt only at cost 10–15 and PBKDF2 only at 10,000–2,000,000 iterations with bounded salt and key sizes; a stored verifier outside the bounds never verifies; both are legacy verifiers that stop verifying after security.password.legacy_hashes_until and are rehashed to Argon2id at the next successful sign-in (credential/imported.go) | TestImportedForeignVerifiers, TestImportedVerifierRawPassword, TestLegacyHashDeadline, TestMigrateFromImportRecordRules | Until the deadline, an offline attacker with a database copy faces the source's weaker work factor for accounts not yet rehashed; the deadline is optional (empty means no end) |
| E: import records that force credential changes or create unexpected state (AUT-018, MIG-005) | A record's required_actions need the same permission as on an existing account (realm.users.credentials.manage for update_password and configure_totp, realm.users.write for verify_email and verify_contact), checked at the request and again in the job; state is only active or suspended; admin_metadata is bounded by limits.metadata_bytes and not editable by users (identity.ImportPermissions, worker/importprofile.go) | TestMigrateFromImportRecordRules | — |
| E/I: migration adapters (MIG-018) | identity-cli migrate-from uses only the ordinary APIs with the administrator's session, so ceilings, owner-role approval, MFA and job authority apply; organizations are matched only by the external ID an earlier run set or a slug the reviewed mapping names, never by name (an automatic slug that exists is a blocking conflict); untranslated source roles grant nothing; POST /migration-plans converts in memory, stores nothing and names records by source ID (internal/migrate, httpapi/migration_adapters.go) | TestMigrateFromKeycloak, TestMigrateFromZitadel, TestMigrateFromSuperTokens, internal/migrate tests | Exports, --write-documents output and plan requests carry verifiers and seeds in clear outside ProudAuth's protection (files are written 0600); the capability report names some source objects (machine users, domains, attribute keys). Without realm.organizations.read the CLI sees only the caller's own organizations and resolves organizations wrongly. The reconciliation the CLI requests has no expected counts |
| E: privilege escalation through a lifecycle workflow (EXT-015) | The publisher needs realm.workflows.publish and must be able to perform every step directly (role ceiling, group role ceilings including ancestors, membership removal, suspension, webhook write); the accountable publisher's authority is checked again inside the transaction of every effect (publisher_lacks_authority fails the run and compensates); manual starters need realm.workflows.run and their own authority over every step, and cannot start runs about themselves; the subject is never an approver and a revision's author cannot approve it; definitions are strict, bounded and contain no code; every reference resolves inside the realm (internal/workflow/authority.go, engine.go) | TestWorkflowDelegationCeiling, TestWorkflowValidation, TestWorkflowOnboardingApprovalAndCompensation, TestWorkflowRevisionsRollbackAndDisable | extensions.workflow_publish_approval applies only to workflows created while it is on; a run keeps its accountable publisher, so a successor's re-publication does not rescue runs in flight; compensation never re-grants removed access, and an owner-role compensation can end compensation_failed |
| T/E: malicious or tampered plugin (EXT-014) | Packages must carry an Ed25519 signature (domain-separated, proudauth-plugin-v1\n) by a key the operator configured, with the manifest binding the payload digest and the manifest's publisher bound to the key; key expiry checked at upload, installation and rollback; plugin names belong to their first publisher; second-administrator review by default, with the approver, uploader and installer each holding every declared permission; strict decoding of manifest and payload; parts validated by the API's own rules; ordinary edits of plugin parts refused, so they cannot drift from the review (internal/plugin) | TestPluginLifecycle, TestPluginSingleAdminReview, TestPluginPartStates, internal/plugin tests | What an enabled plugin's endpoints do is outside the signature; duplicate JSON members inside the manifest or payload are not rejected; single_admin review gives up the second reviewer |
| R/T: historical audit records relabelled as local evidence (MIG-016) | Imported records live in imported_audit_events, never in audit_events or the hash chain; they are always reported with source: "import", verified: false and the source's identifiers, raise no security signal and are not in audit exports; the only local evidence is one audit.history.imported event per request with a digest over the records' content hashes; importing needs the privileged realm.audit.import and an MFA session; source_event_id is unique per source (audit/imported.go) | TestMigrationPlanningAndReconciliation (import-time redaction) | The content is the importer's claim; content_hash only proves what was submitted. Metadata passes through audit redaction by member name and token shape only, so other personal data in the source export comes along. Records for an account already deleted are stored without source details unless a legal hold covers them, deletion clears them (except under a hold), and restore reconciliation clears them again; the hold branch at import and the restore path have no regression test |
| I: reconciliation report exposure (MIG-012) | GET /user-imports/reconciliation needs realm.users.import, returns counts and, for at most 200 privileged assignments, user ID, source ID, role and scope; never credentials or contact data (httpapi/migration.go) | TestMigrationPlanningAndReconciliation | Holders of the import permission learn which accounts hold privileged roles, whatever created the assignment |
| T/E: configuration import weakening policy or bypassing permissions (ARC-008) | Same validation and audit as the API; realm policy floors (realm.ValidatePolicy) and the operator's MFA floor (realm.CheckMFAFloor); applying needs, besides realm.configuration.import, the write permission of every change (realm.settings.write, realm.clients.write, realm.resources.write, realm.roles.write); a realm policy change needs expected_policy_revision and fails if the policy changed since review (422, 412); existing objects change only with ?mode=reconcile, otherwise differences are reported as drift | TestExportsImportsAndJobs, TestConfigImportAuthority, TestConfigReconcile, TestConfigImportResourceAndExchange | The realm policy and client resource grants are updated in place (a grant keeps the subject types and exchange policy the document omits); /validate and dry runs report the missing permissions and revision as plan errors; exchange policies are validated as through the API; a reconcile replaces a client's whole registration from the document (omitted client fields take their defaults), while omitted resource members are kept; a client whose type differs makes a reconcile of it fail as a whole |
| T: concurrent required-action edits (AUT-018, SEC-008) | POST /users/{user}/required-actions updates only if the list is still the one it read (409 conflict, "the required actions changed concurrently; retry") and, with If-Match, only if the account is still at the version the client read the list at (412), so a read-modify-write cannot overwrite a concurrent change; success returns the new ETag. Bulk add_required_action, the factor reset of assisted recovery and passkey enrollment change the list through identity.AddRequiredActionTx/RemoveRequiredActionTx, which move the account version too, so an earlier If-Match no longer matches (identity.SetRequiredActions) | TestRequiredActionEditRace (concurrent blind and conditional edits; retried edits all land) | Without If-Match the list check only covers the request's own read, so a client writing back a list it read earlier still overwrites changes made in between. Actions that sign-in completes inline (password change, email verification, terms, TOTP activation) do not move the version, so a stale conditional edit can re-add an action the user has just completed (the update_password that restore reconciliation adds does move it) |
| E: forcing credential changes on someone else (AUT-018) | Each required action added or removed needs its own permission besides realm.users.required_actions: update_password and configure_totp need realm.users.credentials.manage, verify_email and verify_contact need realm.users.write; bulk operations check the same at request and again in the worker, and so does POST /users for the actions it sets (identity.RequiredActionPermission) | TestRequiredActionPermissions | — |
| I/D: an organization being deleted keeps integrations working (TEN-024) | Starting a deletion disables the organization's service principals, webhook subscriptions and identity providers, revokes its consent pre-authorizations, sessions, grants and automation credentials, erases its job results and cancels its queued jobs in one transaction; finishing deletes its mail server settings and webhook subscriptions and erases identity-provider client secrets (organization.BeginDeletionTx, FinishDeletion) | TestOrganizationDeletionWorkflow | A job already running when the deletion starts can still write its result afterwards |
| I: deletion report discloses data about deleted accounts (PRV-005) | GET /users/{user}/deletion-status needs realm.users.read, is realm-scoped and returns counts, states and dates, no content | TestDeletionPropagationStatus | The report is derived from current data; it does not prove erasure outside the database (backups are dated from privacy.backup_retention); its notification stage counts only messages whose sender recorded the account |
| I/E: ownerless or orphaned integrations (DOM-009, APP-014, MCH-007) | GET /ownership-report (realm.settings.read) flags live clients, projects, service principals, resources, identity providers, webhooks, hooks and authorization policies without an accountable owner or whose owner is no longer active; policies and hooks record an owner at creation; the offboarding transfer moves hooks, webhook subscriptions and policies with the rest (plugin parts keep their plugin), and each has a single-object owner change | TestOwnershipReport, TestOwnershipTransferCoversExtensions | Hooks, policies and webhooks do not block their owner's deletion; the report is a list to act on, nothing is disabled automatically |
| D/E: a compromised client, upstream provider, organization, service principal, hook, webhook receiver or grant class keeps working during an incident | Emergency controls (POST /emergency/controls, realm.emergency, mandatory reason) switch each off without database edits; grant classes are refused at the authorization, device and token endpoints on every node within the 2 s realm cache (httpapi/emergency.go, oauth.grantAllowed); every audit event a control records is filed in the emergency category with the operator's reason (audit.WithOverride), so it outlives audit retention | TestEmergencyControls | Tokens already issued stay valid until they expire; disabling a fail-closed hook lifts the restriction it enforced |
| D: emergency grant control locks administrators out | The operator CLI client proudauth-cli is exempt from grant-class controls (application.OperatorClientID), so administrators can sign in and reverse them | TestEmergencyControls | Anyone holding a proudauth-cli session keeps refresh and code grants during the control |
| I: maintenance and status responses reveal internal topology | Refusals in maintenance mode are a generic 503 maintenance problem with Retry-After and the operator's plain-text message (at most 200 characters, no markup); per-capability status is served only on the internal listener, behind server.metrics_token when set (internal/maintenance, server.operatorStatus) | TestMaintenanceAndStatus | The message is free text chosen by the operator |
| S/R: a service forges or backdates audit evidence (AUD-013) | Reported activity needs realm.activity.report and a service-principal token; types are confined to the app. namespace; events carry source: application, category application and the reporting client; occurred_at is limited to the last 24 hours (httpapi/emergency.go reportedActivityRoutes) | TestReportedApplicationActivity | The content is the service's claim: subject IDs are not checked, and any organization of the realm may be named |
| R: administrators browse account records unnoticed | Realm policy data_access.log_identity_reads records user.read and user.search (category data_access) before the data is returned; support operators get masked views (httpapi/admin.go recordIdentityRead) | TestIdentityReadEvidence | Off by default; covers GET /users and GET /users/{user} only, not other routes that return account data |
| D: admin locked out under load | Separate in-flight pool (a quarter of limits.max_inflight_requests) for /health/* and for requests with an Authorization header to realm API paths containing /users, /settings, /signing-key, /emergency/, /sessions, /clients or /role-assignments (httpapi/app.go isAdminPath, middleware.go) | — | The pool is chosen by path and the presence of a bearer header, before authentication, so any caller sending some Authorization value to those paths competes for it; other admin routes share the main pool |
5. Support impersonation#
Summarized; full analysis in impersonation.md.
| Threat | Mitigation | Tests |
|---|---|---|
| E: operator acts as an arbitrary user | Dedicated permission per organization, recent MFA, reason/ticket, target exclusions, resource opt-in, scope ceiling, read-only default | TEST-040, TEST-041, TEST-042 |
| S/T: token outlives authorization | Opaque, ≤ 5 min, no refresh or exchange, per-request introspection re-checks | TEST-043, TEST-044 |
| R: actor hidden | Actor in grant, introspection (act), IdP audit and resource audit (SupportGuard) | TEST-045, TEST-047 |
6. Workers and outbound connections#
| Threat | Mitigation (code) | Tests | Residual |
|---|---|---|---|
| E/I: SSRF through webhooks, pre-action hooks, back-channel logout, SMS gateway, organization mail servers, federation metadata/JWKS/token calls | egress.Policy: blocks private, loopback, link-local, CGNAT, multicast, documentation and IPv4-mapped ranges at registration and at every dial; no redirects; no proxies; response size and time limits. Webhook subscriptions and hooks reach private ranges only when created with allow_private_network: true, which is refused unless the operator allows private destinations; other subscriptions are held to public addresses at delivery (webhooks.publicPolicy) | TEST-052 (internal/egress/egress_test.go); fuzz FuzzAllowed (the public-only policy never allows private, loopback, link-local, unspecified or multicast addresses) | Operators can widen it with delivery.egress_allowlist and federation.egress_allowlist (allow_private_webhooks and federation.allow_private_network are rejected in production). IPv6 transition ranges that embed IPv4 addresses (6to4 2002::/16, Teredo 2001::/32, local-use NAT64 64:ff9b:1::/48) are blocked like the other special ranges |
| I: personal data sent outside the operator's chosen destinations (PRV-003) | privacy.residency.allowed_destinations (host names or domain suffixes) is checked by the egress policy when webhooks, hooks, organization mail servers, upstream providers and workload key-set URLs are saved, and at every connection of webhooks, hooks, back-channel logout, the SMS gateway, challenge providers, policy engines, organization mail servers, upstream providers and workload key sets; the deployment relay, SMS gateway URL and OTLP endpoint are checked at startup (egress.SetResidency) | TestResidency, TestTransitionAddressesBlocked (internal/egress), TestResidencyAtSave | The deployment's SMTP relay and the OTLP exporter are not checked again when they connect; Vault and KMS commands are not covered; where the database, backups and logs live is the operator's choice and cannot be observed; the startup checks have no test |
| I: trace context sent to outbound destinations (API-012) | Outbound calls made during a request carry only traceparent (trace ID, span ID, sampled flag), never tracestate or baggage; worker deliveries carry none; events carry the causing request's ID as correlation_id (egress.traceTransport) | TestCorrelationPropagation; fuzz FuzzParseTraceparent | Hooks and upstream providers can correlate calls belonging to one request; X-Request-Id and an incoming trace ID are client-chosen, so correlation_id and audit request_id are correlation aids, not evidence |
| I/D: exported spans reveal operational data or load the collector (OPS-003) | Only sampled requests and worker task runs are exported; an incoming traceparent can request sampling only from server.trusted_proxies; spans carry method, route label, status, timings, task names and, for outbound calls, the destination host, never headers, bodies, query strings or tokens; export is batched and drops spans instead of delaying requests; https in production (loopback excepted), no proxy variables, no redirects (platform/tracing) | TestTraceExport (no query string or header attributes), TestOTLPExporter | Route labels are the registered route patterns (or unmatched), so path values never appear; the unmatched label has no test. The exporter does not use the egress policy and sends no credentials, and dropped spans are not counted in a metric |
| S: forged webhooks | HMAC-SHA256 over id, timestamp and body; versioned signature; rotation with overlap | TEST-054 | Receivers must implement verification and replay checks themselves |
| T: duplicate side effects on retry | Leases, SKIP LOCKED, unique (subscription, event), idempotent job handlers | TEST-058 | Email is at-least-once; a crash after SMTP acceptance can resend |
| I: secrets in queued messages | Notification payloads encrypted, purged after retention; dev sink refused in production | QA-007 validation | — |
| D: slow receivers | Per-attempt timeouts, bounded backoff, dead-lettering | — | No per-destination concurrency limit |
| E/T: pre-action hook abuse or failure (EXT-009..EXT-012) | Hooks are operator-registered HTTPS endpoints called through the egress policy with HMAC-signed requests; they can only deny or add obligations, never grant permissions, skip authentication or override reserved claims; registration-eligibility, profile-validation and authorization hooks always fail closed, and claims enrichment fails closed unless set to failure_mode: skip (internal/hooks) | TEST-053 (TestPreActionHooks: unreachable hook fails closed, fail-open security hooks refused, sub override refused, authorization hook denies but cannot grant; TestHookBounds: timeouts and dropped connections fail closed within timeout_ms, every reserved claim, roles and permissions refused, claim count and size and obligation limits enforced) | Each hook receives the request data of its kind; a slow hook adds latency up to its timeout to sign-in, registration, token issuance or decisions |
| T: an extension contract changes under a hook or receiver (EXT-018) | Each hook pins contract_version (sent as ProudAuth-Hook-Version; an echoed different version is malformed and fails the call as its kind fails, closed for security decisions) and each subscription pins event_version; version 1 deliveries are the stored envelope byte for byte; versions stay supported at least 12 months after a successor (internal/contracts, hooks.go, webhooks.go) | TestContractVersions, internal/contracts tests | Hook request schemas are closed although requests may gain optional members within a version, so a hook that validates strictly against them can break on an upgrade |
| T: duplicate or lost workflow effects (EXT-016) | Event triggers start one run per event and workflow (unique trigger key); internal effects commit with the lease check, the succeeded attempt and the run's advance in one transaction, and a step with a succeeded attempt is skipped; endpoint calls carry a stable Idempotency-Key; compensation undoes only the run's own grants (internal/workflow/engine.go) | TestWorkflowIdempotency, TestWorkflowEndpointCompensation | Endpoint calls are at least once: an endpoint that ignores Idempotency-Key can act twice after a crash. Lease ownership is the worker ID (worker-<host name> by default), so two workers with the same host name can both commit |
| D: workflow loops and amplification (EXT-015, TEN-018) | Events a run causes carry the run ID as correlation ID; a workflow is not triggered by its own runs' events; chains through other workflows stop beyond depth 3; a schedule firing starts at most 500 runs; claims are capped per pass and per realm, and a run advances at most 25 transitions per claim; retries are bounded; triggers are limited to eight low-volume event types (internal/workflow/trigger.go) | TestWorkflowScheduleAndOffboarding (self-trigger suppression) | Indirect cycles (A → B → A) run until the depth limit; the depth limit, the 500-run cap, the per-realm share and the transition cap have no test |
| E/I: SSRF and data exposure through workflow step calls | A call_webhook step can only name an existing realm-wide webhook subscription, whose destination passed the egress policy and is checked again at every connection; the publisher needs realm.webhooks.write; step calls carry identifiers only and are signed with the subscription's secret; plugin parts are validated by the egress policy at upload and installation (webhooks/extensions.go) | TestWorkflowEndpointCompensation, existing egress tests, TestPluginLifecycle | The endpoint learns which user a run is about, and when |
| E/D: external policy engine trust and failure (AZ-026) | Engines are configured by the operator only (HTTPS in production, a bearer token that must be a secret reference in production, timeout at most 5 s, answers at most 64 KiB, no redirects, residency applies); a realm opts in; in restrict mode the engine can only deny; an engine that fails, times out or answers outside the contract makes the decision deny (EXTERNAL_POLICY_UNAVAILABLE) unless the realm chose on_error: local; decisions in such realms are never cacheable; tokens are redacted from support bundles (httpapi/policyengine.go) | TestExternalPolicyEngine | In decide mode the engine can grant any application action in the decision API, also to subjects without roles: its compromise is a compromise of those applications' authorization. Engine calls may reach private addresses. The engine receives subject IDs, assurance and the caller's context. An outage with on_error: deny denies every decision of the realm; with local it silently falls back |
7. Database#
| Threat | Mitigation | Tests | Residual |
|---|---|---|---|
| I: database dump | Passwords as Argon2id (optional pepper); bearer secrets as SHA-256; codes as keyed HMAC; recoverable secrets AES-256-GCM with row binding; keys outside the database | internal/keys tests, credential tests | Personal data, IPs and user agents are plaintext; identifier tombstones and rate-limit buckets are unkeyed digests of guessable values |
| T: cross-tenant references | Composite foreign keys (realm_id, id); organization-scoped unique indexes | Isolation tests | No row-level security |
| T: audit tampering | Append-only triggers; hash-linked batches signed with an Ed25519 key derived from the root key (audit/signing.go); retention removes only expired sealed events (never emergency or held events) through the owner-run function proudauth_audit_prune, which records every pass in the append-only audit_prunes and audit_prune_log ledgers so the chain still verifies, and with storage.runtime_role the service's credentials have no DELETE on the audit tables (AUD-007); corrections are new linked events (audit.correction), never edits; verifier (identity-cli verify-audit -require-signatures) plus an hourly check of recent batches (audit.verify_recent, proudauth_audit_integrity_problems); optional write-once archive of every sealed batch to operator-controlled storage, verifiable without the database (audit/archive.go, identity-cli verify-audit-archive) | TestAuditEventsAreAppendOnly, TestAuditEvidenceAndRedaction, TestAuditSignaturesAndRetention, TestAuditRetentionCategoriesAndHolds, TestAuditArchive, TestAuditCorrections | The prune function does not check age: a stolen runtime credential can prune sealed, unheld, non-emergency events early, recorded like retention, and can change audit_legal_holds and audit_archive_state; without separate roles the runtime credential is the owner and can delete rows directly. A superuser who also holds the root key can disable triggers and rewrite and re-sign the chain, which only an archive on storage they cannot change reveals; batches sealed before signing was enabled are unsigned; with an archive configured, retention prunes only archived batches; after a restore the archive continues in a fork directory and the verifier checks the fork's link to the main archive (TestAuditArchive); the verifier reports gaps, short files after a complete one and a first batch that does not start the chain, but cannot see missing newest files or missing leading files (compare first_sequence and last_sequence with the database); no external anchoring |
| T: evidence pruned during an investigation (AUD-009) | Legal holds (realm.audit.legal_hold, MFA session, reason) keep a realm's, an organization's or a subject's events from retention; placing and releasing are audited and a release raises security.alert | TestAuditRetentionCategoriesAndHolds | An older worker in a mixed-version rollout ignores holds; holds cover only events with a realm; on imported historical records a person's hold matches only records mapped to that person as subject. Realm and organization retention (audit.retention_days, audit_retention_days) only extend retention; for imported records they count from the import |
| T: race conditions | Row locks, conditional updates, partial unique indexes, advisory locks | TEST-016, TEST-023, TEST-030, TEST-011 | — |
| T: transaction retries repeat external effects (DB-014) | Deadlocks and serialization failures (SQLSTATE 40P01, 40001) are retried up to 4 attempts; the token endpoint and authentication steps run once (storage.NoRetry) when the realm has an active pre-action hook, so hook calls are never repeated, and tokens are returned only after their transaction commits; webhooks, email and SMS leave through the outbox and notification jobs after commit (storage/storage.go) | TestTransactionRetries | Flow-challenge answers call the provider outside any transaction (after the continuation proof and pending challenge are checked) and apply its answer in the step's own transaction (SEC-014), and device-code and token-exchange retries reset their result; rate-limit counters, which are written outside the transaction, count a retried attempt twice; the reset-per-attempt paths have no regression test |
| E/T: the service's own credentials change the schema or the audit guards (OPS-013) | Optional role separation: migrations run with storage.migration_database_url (the schema owner) and the runtime role receives only SELECT, INSERT, UPDATE, DELETE and sequence use after each run, plus default privileges for later tables, with CREATE on public and DELETE on the audit tables revoked (storage.GrantRuntime); the audit guard accepts deletes only from the owner inside proudauth_audit_prune; operator commands never apply migrations, and every migration run is audited (operations.migrations.applied); identity-cli doctor warns when the runtime role is a superuser, owns the schema, inherits the owner's privileges, can create roles or can create objects in public | TestRuntimeRoleSeparation (skipped without a role that can create roles) | Off unless configured. The runtime role can still prune sealed events early through the function (see audit tampering). The migration also revokes CREATE on public from PUBLIC (granted by default on PostgreSQL 14 and older), which takes effect only when the migration role owns the schema or is a superuser. Operator commands that cannot write their audit record (migrate, revocation-log, support-bundle) fail with an error after acting; restore-reconcile records the operator's OS user as actor. A NOINHERIT member of the owner role is not detected. Nothing prevents setting runtime_role to the owner role itself |
| R: restore resurrects revoked access or consumed proofs | Revocation log + reconciliation, including proof consumption (rotated and revoked refresh tokens, revoked grants, consumed and replaced recovery codes, HOTP counters, initial-access-token uses and revocations, rotated registration access tokens); every outstanding one-time proof of the restore (reset, verification and setup codes and links, authorization and device codes, pending sign-in transactions) ended; a restore marker (identity-cli restore-begin) keeps /health/ready at 503 restore_pending and identity-worker stopped until reconciliation clears it (internal/restore) | TEST-060 (TestRestoreReconciliation, TestRestoreProofConsumption, TestRestoreDrill) | Not all revocations are logged (backup-restore.md); the marker is set by the operator, so a forgotten restore-begin leaves no gate; replay caches (DPoP, client-assertion and SAML assertion IDs) lose entries recorded after the backup; anything changed after the last log export is covered only by -invalidate-all-sessions |
| D: unavailability | Fail closed for issuance, introspection, administration | TEST-035 | Offline JWTs continue until expiry |
8. Keys#
| Threat | Mitigation | Tests | Residual |
|---|---|---|---|
| I: key disclosure | Keys only as secret references (files, environment, systemd credentials, Vault) or KMS-wrapped files unwrapped at start-up by an operator command run without a shell (configuration/kms.go, DEP-016); never in exports or support bundles; /status reports key_provider | Support-bundle redaction (code review); TestKMSUnwrap | Keys are used in software: every process holds them unwrapped in memory (not zeroed), so process-memory access or the service's KMS credentials yield them; no signing or decryption inside an HSM. The KMS command inherits the process environment except PROUDAUTH_* variables, so other secrets in the environment reach it |
| S: forged tokens after compromise | Emergency rotation marks the key compromised, removes it from JWKS, server rejects it | TEST-036 (TestSigningKeyRotation) | Offline verifiers accept the key until they refresh JWKS |
| T: wrong keys after restore | Keyring fingerprint in doctor | — | — |
| D: loss of root key | Documentation (backup separately) | — | Unrecoverable data |
9. SDK verifiers#
| Threat | Mitigation (proudauth-sdk-go/verifier) | Tests | Residual |
|---|---|---|---|
| S: untrusted issuer | Exact issuer check before any key fetch | TEST-003 | — |
S: algorithm confusion, none, HMAC | Asymmetric allowlist enforced in configuration and parsing; key alg must match | TEST-004, FuzzVerify | — |
D: unknown-kid flood | One coalesced refresh per MinRefreshInterval | TEST-005 | — |
| T: ambiguous JSON | Duplicate-member rejection; duplicate kids in JWKS dropped | TEST-004, FuzzRejectDuplicateKeys (server) | — |
| E: support token treated as ordinary token | Offline verifier refuses impersonation_access; introspector rejects it unless via SupportGuard | TEST-045 | — |
| S: stolen sender-bound token treated as bearer authority | Every SDK token-only path refuses cnf; Go SenderVerifier checks DPoP method/URL/hash/key, atomic single-use replay state, or the TLS leaf-certificate digest | TEST-067, TestResourceSenderVerifier, TestSenderProofs, TestSenderReplayConcurrencyAndFailure, shared access-token fixtures | Multi-instance APIs need a shared atomic proof store; proxy callbacks must authenticate the proxy and its TLS evidence; no resource nonces; Node.js, Dart and PHP have no request-level sender verifier |
| I: credentials or sensitive bodies forwarded by an HTTP redirect | Go SDK requests use proudauth-sdk-go/internal/httpclient, which returns the redirect response without following it, preserving caller settings | TestRequestsDoNotFollowRedirects | Applications must configure trusted endpoint URLs and protect the transport; application-owned browser redirects use a separate flow |
| S: stale revocation | Documented exposure; strict-online mode available; bounded-state mode (BoundedState, BoundedVerifier; Node.js bounded-state) verifies locally, applies session, subject, organization and membership revocations replayed from the session-event stream, and fails closed (ErrStateStale) when its state is older than MaxStaleness | TEST-034, TEST-035, TestBoundedStateVerification | Offline exposure until expiry; bounded-state exposure up to MaxStaleness, and revocations before Start are not replayed |
10. Operator channel and deployment#
| Threat | Mitigation | Residual |
|---|---|---|
| E: insecure production configuration | Validate() rejects development shortcuts in production mode (literal root, pepper and signing-wrap keys, SMTP password, metrics token, email-provider callback secret and SMS gateway secret; passwords embedded in the database URL; dev sinks; plain SMTP; insecure cookies; weak Argon2 memory; federation.allow_private_network and delivery.allow_private_webhooks; observability.log_level: debug); malformed environment overrides stop startup instead of reading as false/0 (DEP-020) | TLS to PostgreSQL not enforced |
| T: unreviewed configuration change weakens policy | No file watcher: configuration is read only at process start, so a transient parse error never reaches a running process; identity-cli config-diff validates a proposed file and marks keys that move a security boundary (configuration.Diff), including server.host_policy, server.public_listen, the TLS files, storage.migration_database_url, storage.runtime_role, observability.otlp_endpoint, workers.metrics_listen, the mutual-TLS keys and the saml.* and directory.* boundary keys (TestDiffBoundaryKeys, TestDiffMutualTLSBoundaries) | Review is not enforced |
| I: secrets or personal data in debug logs | Debug level is refused in production; identity-cli debug-window opens a window of at most 1 hour, optionally for one component, audited when opened and closed; records still pass through key- and pattern-based redaction (observability.SetDebugWindow) | Redaction is name- and pattern-based; debug records can contain identifiers and request details |
| I: development mode exposure | The mail sink viewer exists only in development mode and only on the private operator listener; production configuration rejects the sink | An exposed operator listener of a development instance leaks codes |
| T: supply chain | Immutable base-image OCI digests and an exact release toolchain shared by the image and binaries (TestReleaseToolchainPinned); measured byte-identical reproduction of all 12 development release targets by Go 1.27.2 on macOS/Linux, with frozen inputs, isolated caches and offline controls (TestLocalReproductionRefusesMismatches); go mod verify, license/secret scans, govulncheck on every shipped platform, staticcheck and Dependabot in CI; the release workflow defines image digests, SBOMs, build-provenance/SBOM attestations and keyless cosign signatures (security-checks.md) | Frontend and GitHub Actions still float by tag; the image's OS layer is not scanned; one-host binary equality does not establish an independent release builder; the official release, bundle and image are not reproducibly verified off-host |
| S: malicious deployment operator | Explicit limit: operators with database, keys and hosts can do anything (§23.2) | Use separate deployments and key custody for stronger isolation |
Acceptance tests: coverage notes#
From docs/plan.md §33.2: tests that were previously uncovered, and those still not or only partly covered.
| Test | Status |
|---|---|
| TEST-024 | Covered: TestPasskeys subtest "TEST-024 assertions with wrong challenge, origin, RP ID or flags are rejected" (wrong challenge, origin and RP ID at assertion; a UP-only assertion under the phishing-resistant profile) |
| TEST-026 | Covered: TestFederation subtests "existing account requires proof of control" and "TEST-026 unverified upstream email never claims an account" |
| TEST-027 | Covered: TestFederation subtest "TEST-027 SSO-mandatory organization has no password fallback" |
| TEST-048 | Covered: TestSCIMProvisioning (deactivating and deleting a user who also belongs to another organization suspends and then removes only the connector's organization membership; the account and the other membership stay active) |
| TEST-049 | Partly covered: a connector whose role_ceiling_ids exceed its creator's delegation ceiling is refused (TestSCIMProvisioning), and mappings or a default role outside the ceiling are refused with 422 (no test). At sync time, mapped roles outside the ceiling are skipped without a reconciliation error. A connector cannot add members to a group whose roles, granted by anyone or inherited, exceed its ceiling (403, TestSCIMProvisioning) |
| TEST-050 | Covered: a stale If-Match on a SCIM PATCH gets 412, and identity attributes of an account the connector does not own are refused (400 mutability) (TestSCIMProvisioning). If-Match is optional. The SCIM ETag is derived from the resource's content, so a change made through the administration API to a provisioned attribute or member makes a SCIM write with the old tag stale (TestSCIMProvisioning, "tag stale after an admin edit"). SCIM PUT, PATCH and DELETE lock the account and its membership (users) or the group row before comparing the tag, the rows the administration API locks or writes, so an administrator's change that commits while a SCIM write is in flight makes it fail with 412 instead of being overwritten, and a SCIM write without If-Match is applied on top of it (TestSCIMAdminRaceNeverLosesChanges: users by PATCH and PUT, groups, and concurrent SCIM and administration API writes) |
| TEST-051 | Covered against the project's own test SP and IdP: TestSAMLBroker (invalid audience, destination and recipient, InResponseTo and RelayState mismatch, unsigned and untrusted signatures, SHA-1, five signature-wrapping shapes, duplicate IDs, DTD/XXE, a comment in the NameID, oversized messages, response and assertion-ID replay; no account is created or linked on any of them) and TestSAMLIdentityProvider (request signature, replay, DTD/XXE, unregistered ACS); parser bounds in TestParserRefusals and TestRedirectBinding (saml.md). Against a third-party product, Keycloak 26.8.0 (scripts/interop-keycloak.sh): its AuthnRequest with a changed RelayState (signature) and replayed, and its SAML response with a changed attribute (signature) and replayed, are refused, with no account created (interoperability.md) |
| TEST-053 | Covered: TestPreActionHooks (unreachable hook fails closed, fail-open security hooks refused, sub override refused) and TestHookBounds (a hook that exceeds its timeout_ms or drops the connection fails closed within the timeout for registration eligibility, claims enrichment and authorization decisions, with diagnostics categories timeout and unreachable, and no account or token results; every reserved claim, roles, permissions and over-long claim names are refused and never reach a user or machine token, also with failure_mode: skip; more than 20 claims or 4 KiB of claims refused, exactly 20 and 4 KiB accepted; more than five obligations deny with HOOK_INVALID_RESPONSE); unit bounds in TestClaimsAnswerBounds. Profile-validation and risk hooks share the call path, but their timeouts are not tested separately |
| TEST-055 | Covered: TestForgedProxyAndIdentityHeaders (rotating X-Forwarded-For from an untrusted peer does not evade the per-IP budget; identity headers are never authentication); TestTrustedProxyForwarding shows forwarded addresses are used only from server.trusted_proxies |
| TEST-059 | Covered with fault-injection seams (storage.DB.SetFault fails a chosen transaction at its start or just before its commit; keys.Keyring.SetFault makes the key provider unreachable for a purpose; and, already, impersonation.FailAudit and the TEST-035 introspection outage). Database: TestTransactionFaultsLeaveNoPartialState fails every database transaction of a refresh rotation, authorization-code exchange, password-reset completion and invitation acceptance in turn, at begin and at commit; each failure answers an error with no token, leaves the refresh token, code, reset code or invitation unconsumed and no effect (no new generation, no reuse or replay revocation, password and memberships unchanged), and the same request then succeeds once. Key provider: TestSigningKeyOutageDuringIssuance (no active key, or the keyring cannot unwrap it) answers 503 temporarily_unavailable for the code, refresh and client-credentials grants without consuming the code or refresh token, reports readiness degraded, creates no key, and the same request succeeds once the key is back. Mail: TestDeliveryOutageDuringSensitiveFlows (password reset, registration email verification, invitation) answers as when mail works (a reset for an unknown address answers the same), retries the message to failed, and changes nothing about the account (password, verification state, memberships); a resend after the outage completes verification. Cache: not applicable, ProudAuth has no cache tier (no Redis or memcached; rate-limit counters and replay caches are PostgreSQL tables), so a cache failure is a database failure; the in-process caches (signing keys, realm settings, maintenance state) are reloaded from the database after a few seconds and a failed reload is a database failure too. Not injected: failures of individual statements in the middle of a transaction (equivalent to the commit failure, as the transaction aborts as a whole), PostgreSQL connection loss mid-request, SMS adapters, and other flows than the four above (device codes, PAR, magic links, passkeys) |
| TEST-061 | Covered: TestMixedVersionWindow (internal/storage): an older binary keeps working after newer expand-only migrations, refuses to migrate a newer schema, and fails clearly after a newer contract migration |
| TEST-062 | Covered: TestStreamOutlivingAuthorization (a long-polling consumer whose permission is revoked gets 403 before its wait ends) |
| TEST-063 | Covered for ProudAuth's own job queue: TestForgedJobMetadata (job rows naming another requester, subject or tenant dead-letter without acting). ProudAuth provides no consumer for application queues |
| TEST-066 | Covered: TestDynamicClientRegistration (privileged scopes refused for initial access tokens, jwks_uri and software statements refused, unsafe redirect URIs refused, open registrations held for approval; management with a wrong, administrator, other-client or rotated token refused) and TestDynamicClientRegistrationBounds (a scope made privileged after an initial access token allowed it is not granted at registration and cannot be requested by the registered client, while the token's other scopes still are; sector_identifier_uri, request_uris, initiate_login_uri, backchannel_logout_uri and subject_type ignored and not stored; no connection reaches any registrant-supplied URL during registration, sign-in, a request_uri naming one, or session revocation with the worker's deliveries, with private destinations allowed). Not covered: rejection, require_approval with a token, the budgets, private_key_jwt registrations |
| TEST-067 | Covered: TestDPoPBoundTokens and TestMutualTLS (the IdP's APIs and UserInfo refuse DPoP- and certificate-bound tokens without the matching proof or certificate). Resource servers outside ProudAuth must enforce cnf themselves; the SDK verifiers do not |
| TEST-072 | Covered: TestOfflineOperation (bootstrap, sign-in, token issuance, administration and every worker task with non-loopback network and DNS blocked) |