Skip to content

AuthFramework v0.6.0-rc166

Choose a tag to compare

@github-actions github-actions released this 29 Oct 16:38
· 35 commits to main since this release
8beab70

Changelog

All notable changes to the AuthFramework project will be documented in this file.

The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.

[0.6.0] - Unreleased

Added

  • 0.6.0-rc155 -- PersistantStorage: an AuthStorage over a PersistAnt key-value backend (feature persistant-storage, off by default). Tokens, sessions and key-value entries are one record per key with secondary lookups (by access-token hash, by user) written in the same atomic batch, so a record and its indexes appear and expire together; a record past expires_at is gone at once, where SqliteStorage keeps returning it until cleanup_expired. swap_kv is emulated with a batch precondition (supports_atomic_swap() is true). Needs a backend with atomic batches (PersistAnt's memory, SQLite, PostgreSQL, MySQL/MariaDB). New dependencies persistant-kv and persistant-sqlx 0.11.7 are optional and only compiled with the feature. storage::persistant_migrate::migrate_sqlite_to_storage copies a SqliteStorage database into any AuthStorage (needs sqlite-storage too); the example persistant_storage_speed times both (about 7 ms vs 6 ms per write, fsync-bound; get_token_by_access_token 672 us vs 326 us; list_user_tokens 2.1 ms vs 0.35 ms on the box it was run on). SqliteStorage is not deprecated.
  • 0.6.0-rc97 -- A permission check can now require proof that the token was validated. AuthFramework::validated_token
    validates a token (signature, expiry, revocation, stored copy) and returns a ValidatedToken, which only the framework can
    construct; check_validated_permission on AuthFramework, AuthorizationManager and PermissionChecker takes that proof,
    so a hand-built AuthToken cannot reach it. AuthFramework::check_permission behaves as before and now goes through the
    same path. PermissionChecker::check_token_permission and AuthorizationManager::check_token_permission, which never
    validated the token they were given, are deprecated in favour of the new methods; they still work.
  • 0.6.0-rc91 -- AuthStorage::swap_kv: remove keys and store another in one atomic step. swap_kv(remove, key, value, ttl)
    succeeds only if at least one of the remove keys is live and key is absent: it then deletes every remove key and
    stores value under key (returns true); otherwise it changes nothing and returns false. Of several simultaneous
    swaps of one key exactly one succeeds. supports_atomic_swap() says whether a backend implements it; the default
    implementation fails with an error and supports_atomic_swap() is false, so existing custom backends are unaffected.
    SQLite and PostgreSQL do it in one transaction (a refused insert rolls the delete back), Redis in one server-side
    script, the encrypted wrapper encrypts the stored value under the new key's own binding, and the in-process backends
    (memory, DashMap, unified) take one lock shared by all swaps: they are atomic with respect to other swaps, but a
    concurrent reader can see both keys in the instant between the store and the deletes. Tested against all of these
    (SQLite in process; Redis 7 and PostgreSQL 15 with servers; the refused-swap, expired-key, lifetime and 8-way race cases;
    seven mutations of the guards each fail the suite).
  • 0.6.0-rc90 -- API key rotation. UserManager::rotate_api_key(old_key) (also on AuthFramework and
    tokens(), plus rotate_api_key_with_expiry) replaces a key with a new one for the same user and role and returns the
    new key once; the old key stops validating. The new key lives as long as the old one was meant to (a key that never
    expired still never does), counted from now, or as long as the caller chooses. A revoked, expired, unknown, or
    role-no-longer-permitted key is refused and nothing is created. Of several simultaneous rotations of one key exactly
    one succeeds (an atomic store_kv_if_absent claim). The new key is stored inert first, the old one deleted second, and
    the new one made live last; if the delete fails the new key is removed again and the call returns an error, so a
    returned result always leaves exactly one valid key. A process that dies part-way never leaves two live keys: a death
    before the old key is deleted leaves the old key as the only live one (retry after the 60-second claim expires), and
    a death after it leaves no live key until the user creates another (an inert row never authenticates and expires on
    its own where the backend tracks TTLs). "Never both and never neither" needs a storage transaction, which is not
    provided. A backend without an atomic store_kv_if_absent refuses to rotate. The single-winner behaviour and the
    crash behaviour at each of the six writes are tested against the memory, SQLite, Redis and PostgreSQL backends
    (8 concurrent rotations per round; disabling the claim fails all four); any other backend is unverified.
  • 0.6.0-rc89 -- API keys carry a role. UserManager::create_api_key_with_role(user_id, role, expires_in) stores the
    role with the key and validate_api_key returns it (UserInfo.roles). The allowed roles are admin, user and
    api_user by default and configurable with UserManager::with_api_key_roles; a role outside the set is an error at
    creation (nothing is stored; names are compared exactly). A stored key whose role has since left the set fails
    validation rather than keeping it or being re-labelled. create_api_key is unchanged and creates api_user keys, and
    keys stored before roles existed read as api_user, so no migration is needed. AuthFramework::create_api_key_with_role (and
    tokens().create_api_key_with_role) expose it. Authenticating with an API key copies its roles into the token's roles
    and scopes: an admin key yields exactly the scope and role admin (what the crate's admin checks look for), a
    user key does not, and no other role name equals a scope name the crate checks (read, write); tests pin all
    three. Who may mint an admin key is the caller's authorization decision.
  • 0.6.0-rc86 -- TOTP secrets can be encrypted at rest under a key you supply. TotpManager::new_encrypted(inner, &key_provider) wraps
    the storage in the existing AES-256-GCM EncryptedStorage (plaintext and legacy reads off), so the row in the backing store is an
    envelope bound to its own storage key: a row copied onto another user's key, or read with a different key, does not verify, and a
    row that is still plain is refused. Existing plain secrets are encrypted in place by TotpManager::migrate_plain_secrets, which
    touches exactly mfa_secret:* and user:*:totp_secret (not the rest of user:), and a key rotation is finished by
    TotpManager::rewrap_secrets (rows under an older key id are re-encrypted under the current one); both are idempotent, keep each row's
    TTL, and have dry runs. Plain storage is unchanged: TotpManager::new behaves as before and logs ONE warning at construction when the
    storage does not encrypt at rest; TotpManager::new_plaintext is the silent, explicit opt-in. New in storage::encryption:
    migrate_kv_keys_to_encrypted (the migration for an explicit key list), rewrap_kv_keys, KvRewrapReport,
    StorageEncryption::current_key_id. Tested on the memory, SQLite, Redis and PostgreSQL backends.

Fixed

  • 0.6.0-rc148 -- Removed: the third-party caveat and discharge API of protocols::macaroons (add_third_party_caveat, verify_with_discharges, Caveat::vid, Caveat::cl, Caveat::is_first_party). The API was incomplete. A serialized credential that carries a third-party caveat is now refused when it is parsed. First-party caveats are unchanged, and the signature comparison is now constant-time. Not a guarantee: the crate does not interpret caveat predicates, so a deployer can still write a first-party predicate such as needs-discharge: x and check it in the verifier closure; anything built that way is the deployer's own code and is not verified or supported by this crate.
  • 0.6.0-rc135 -- crypto-bigint 0.7.5 -> 0.7.7 (RUSTSEC-2026-0387, timing variability in constant-time operations). Lockfile-only: cargo audit reports the vulnerability on main and exits 0 after the update. RUSTSEC-2026-0374 (basic-toml, unmaintained) stays waived in deny.toml with its removal condition: it is a proc-macro dependency of the newest askama, web-gui only.
  • 0.6.0-rc126 -- WS-Federation sign-in tokens are verified before they are read. WsFederationClient::process_sign_in_response now accepts a SAML 2.0 or 1.1 token only when its signature verifies (with bergshamra, via methods::saml::dsig) under a key you configured: WsFederationConfig::trusted_signing_certificates (issuer and certificate) or trusted_cert_thumbprints (a certificate the token carries is used only if its SHA-256 thumbprint is listed), and only the signed assertion is read. Configuration changes: WsFederationClient::new fails unless at least one certificate or thumbprint is configured; JWT sign-in tokens are no longer accepted; without the saml feature no token is accepted. Also: a token with no NotOnOrAfter no longer overflows the expiry check, and SAML 1.1 saml:-prefixed Conditions are read.
  • 0.6.0-rc125 -- SAML responses: the assertion that is consumed is the one that was verified, and issuer certificates are pinned. Signatures are now verified in one place (methods::saml::dsig, on bergshamra) under the certificate configured for the assertion's issuer, with every reference digest recomputed; only the signed Assertion (found by its unique ID) is read afterwards, and InResponseTo is taken from the signed assertion unless the Response itself is signed. The ACS endpoint, SamlAuthMethod and SAML token exchange all use it. Configuration changes: TokenExchangeManager::with_trusted_saml_issuer(issuer, certificate) must be called for every issuer whose SAML subject tokens are accepted (with none configured, every SAML subject token is refused; the certificate a token carries is no longer used); POST /api/v1/saml/sso refuses an IdP whose saml_idp:{entity_id} has no usable signing_cert; SamlAuthMethod::validate_config fails when no identity provider is configured, a certificate does not parse, or validate_xml_signature is false. Removed: XmlCanonicalizer and the hand-written signature checks behind SamlSignatureValidator (which keeps validate_xml_signature(xml, cert_der), now backed by bergshamra); SamlSignatureValidator::extract_embedded_certificate.
  • 0.6.0-rc121 -- The Debug audit now checks fields of a type it does not know, and three secrets it found are redacted. The derive(Debug) audit skipped a hand-written Debug that printed a field of a custom type (Zeroizing, Map, Algorithm, RsaKeyPair, ...) as it is. It now fails unless the type is one it has audited. That found MfaSetupResponse.secret (the TOTP secret) and the OAuth 1.0a RequestTokenResponse / AccessTokenResponse oauth_token_secret, which Debug printed in clear: all three now print <redacted>. BackChannelLogoutManager.rsa_signing_key is redacted too, and three fields that hold only public information (VerifiedSdJwt.plaintext_claims, and the JWS algorithm names of AdvancedJarmConfig and FapiConfig) are on the reviewed list with their reasons.
  • 0.6.0-rc120 -- CI: no job holds a repository secret, and the unused AWS deploy jobs are gone. The Deploy to Staging, Deploy to Production and Notify Team jobs of ci-cd.yml pushed images to Amazon ECR and updated an ECS service with AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and posted to Slack with SLACK_WEBHOOK. None of the three secrets exists, no staging or production environment is defined, there is no develop branch, and the last two release-event runs skipped all of them. They are removed; container images are published to ghcr.io by release.yml with the workflow token. Every workflow already declares top-level permissions: contents: read.
  • 0.6.0-rc119 -- Dependencies: tokio 1.54.0 -> 1.54.1 in Cargo.lock (RUSTSEC-2026-0384). Tokio 1.54.0, now yanked, has a race between tokio::time::Interval::poll_tick and the time driver on the multi-threaded runtime that can free a timer entry still in use (use-after-free). The crate spawns loops that tick() an Interval (storage cleanup every 60 s, the architecture cleanup task, the deployment health monitor, CAEP continuous evaluation), so a build locked to 1.54.0 was exposed. The requirement in Cargo.toml rises from 1.47.1 to 1.54.1, so an application that depends on this crate cannot resolve the affected 1.54.0 either.
  • 0.6.0-rc118 -- Debug, Display and the admin CLI no longer print webhook URLs, Redis passwords, session ids or header values. SlackConfig printed its incoming-webhook URL (Debug is now hand-written and shows <redacted>, as WebhookConfig does). StorageConfig's Display, and so AuthConfig's, printed the whole Redis URL, password included; it now prints redis(redis://host:port). RpInitiatedLogoutResponse printed the ended session ids and now prints their fingerprints. AuthorizationContext printed the values of its custom attributes, which are read from X-Auth-* headers and ctx_* query parameters; it now prints only their names, and so does StepUpContext for its own custom attributes. auth-framework-admin config show and config get masked nothing: every string under a key named like a secret (secret, password, token, key, credential, ...) and the user info and secret-named query values of any URL now print as <redacted>; numbers and booleans are kept. The new --show-secrets flag on both commands prints the real values (config show says so on stderr). Tests were written first: five Debug/Display checks in tests/secret_display.rs failed with the marker in the output; the masking and flag-parsing tests are in src/admin/mod.rs, and turning the masking off makes the masking test fail.
  • 0.6.0-rc117 -- Log lines and error text no longer carry session ids, CIBA request ids, webhook URLs, license keys or one-time codes (round 2). The log audit cut each file at its first #[cfg(test)], so code after a test-only item was never scanned; it now skips only the test item. It also reads field access (session.session_id, logout_request.session_id, session.id), *_str names, auth_req_id, lists of ids and the arguments of a nested format!, json! or serde_json::to_string. Twenty-four calls it now finds (CIBA, stepped-up authentication, federated orchestration, CAEP, OIDC extensions, the CIBA endpoint) log redact::fingerprint(..) instead. Found by reading the sites the scanner cannot: the development e-mail and SMS providers logged whole message bodies (one-time codes) and now log their length; the session revocation entry, notification and audit event, the storage audit line, the device-mismatch audit detail and a session-not-found error carry a fingerprint; alert webhook URLs (Slack, Teams, generic) and the CAEP webhook are logged as scheme://host by the new redact::url_origin; the CAEP quarantine line logs a count, not the session ids. A reqwest error prints the request URL, so the YubiKey (OTP in the URL), CAS (ticket in the URL), threat-feed (MaxMind license key in the URL) and alert errors now go through without_url(). Tests were written first: a fixture with a test-only item before a leak found nothing on the old scanner; the 24 sites failed the audit before the fixes.
  • 0.6.0-rc116 -- Passkey authentication works with real credentials, and an assertion can be used once. PasskeyAuthMethod could register a credential but could
    never authenticate one: it looked for the public key at a JSON path the serialized credential does not have (so every registration stored null), and it read the
    assertion's clientDataJSON, authenticatorData and signature only as base64url text although passkey-types writes bytes as arrays. Registration now converts the
    credential's SubjectPublicKeyInfo to a JWK (ES256, ES384 and RS256; anything else, or a malformed key, is refused and nothing is stored) and reads the sign count from the
    authenticator data; verification accepts both byte encodings. New register_credential stores a credential an authenticator created elsewhere and registered_passkey
    reads one back. With authentication able to succeed, the pending challenge is now single use: complete_authentication removes it whether or not verification passes,
    so an assertion cannot be replayed (platform authenticators report a zero counter, which the counter check cannot catch). Tests were written first: the recorded
    passkey 0.5.0 assertion fails on the old code ("Missing clientDataJSON"), a live registration and assertion from the passkey client authenticate, and a replay, a
    tampered signature, a wrong challenge, another key and an unregistered credential are all refused; putting the challenge back after use fails the replay test.
  • 0.6.0-rc114 -- Log lines no longer carry session ids, tokens or one-time codes. Forty-six trace!/debug!/info!/warn!/error! calls
    wrote a session id (session manager, secure sessions, CAEP, OIDC extensions, rich authorization requests, user and session
    endpoints), an event JWT (OIDC extensions), the e-mail MFA code or the YubiKey response as they were. High-entropy identifiers are now logged
    as redact::fingerprint(..) (sha256: and four bytes of the digest, enough to match two lines about the same value and no way back);
    one-time codes and challenges are not logged. A new audit test fails when a log macro is passed a bare session id, token, code,
    password, challenge or similar identifier, as an argument or inline in the format string; wrapped values, field accesses, literals,
    comments and test code are not reported. It fails on this tree before the change (46 sites) and when any site is put back.
  • 0.6.0-rc113 -- Hand-written Debug impls are audited field by field, and the fields it found are redacted. The audit checked derived Debug only
    for plain-text fields and hand-written impls only for maps, so an impl that printed token, code or a user code as it was passed.
    Every .field(..) of a manual impl must now go through redacted(..), be a literal or a count, or name a field the review file lists
    as public (or allow it per type with a reason); the same check reads struct field types where the impl prints &self.member, and falls
    back to the field's label for enum arms. Redacted: introspection token, client_assertion, registration_access_token, the JARM
    authorization code, the MFA code of a login request, device user_code and verification_uri_complete, the GNAP interact_nonce,
    OIDC session logout_tokens and metadata, token custom claims, a threat-feed URL, a token-exchange jwt_verification_key, and the
    password and configuration value typed on the admin command line. logout_tokens, interact_nonce and verification_uri_complete moved from the
    public to the secret-capable name list. Tests fail on the old audit (57 findings on this tree) and on removing any redaction.
  • 0.6.0-rc112 -- Maps, sets and JSON values no longer print their keys and contents in {:?}, and four identifiers that act as credentials are redacted. The
    derived-Debug audit looked at plain-text fields only, so a field such as DashMap<String, AuthToken> (keyed by token or session id)
    was never checked; DashMapMemoryStorage, MockStorage, SessionStore, the CIBA, HEART, eKYC, FAPI and OIDC session managers, the
    step-up manager, DeviceFlowManager (device codes) and about sixty more types printed them. The audit now reads container field types (DashMap,
    HashMap, BTreeMap, sets, serde_json::Value, through Option, Arc and locks), for derived and for hand-written Debug impls: a
    container's name must be listed as holding no credential ([container_ok]) or the type redacts it. Redacted: those stores and
    managers, parameter bags that can carry credentials (authorization and introspection request parameters, token-response extras, outbound
    headers, identity-provider settings, SCIM bulk data, domain-event payloads), RADIUS reply attributes and State, and the sid claim
    (filled from the session id in this crate), session id, MFA code_hash and hashed client secret and registration token, which were wrongly listed as public by name.
    Tests were written first or fail when the redaction is removed.
  • 0.6.0-rc111 -- The derived-Debug audit now sees one-line enum variants, and four more types stop printing credentials. The audit read one
    field per line, so a variant written External { endpoint: String, api_key: String }, was never checked. It now parses variants and
    structs written on one line, #[cfg_attr(.., derive(Debug))], attributes that span several lines, and the byte and text shapes
    Vec<Vec<u8>>, Box<[u8]>, Cow<str>, Bytes and [u8; N]; a further test fails if a macro defines a type, which the scan cannot see.
    Redacting Debug on AuditStorage (the external-service key and the database connection string), DeviceAuthState (the access
    token of an authorized request) and CaepRuleAction (a webhook URL can carry a token); the other findings were reviewed and listed as
    public (certificate stores, scope lists, the RADIUS authenticator, which travels in the clear). Tests were written first and fail on the
    old scanner; tests/secret_debug_redaction.rs gains one case per redacted type. ROADMAP: the scheduled branch-audit row is ticked.
  • 0.6.0-rc108 -- The JavaScript SDK (sdks/javascript) typechecks, builds and tests again, and CI now runs it. It had no CI and had
    rotted: tsc reported three exactOptionalPropertyTypes errors (AuthFrameworkError.statusCode, RateLimitError.retryAfter
    and the request config in base-client.ts), npm run build failed because tslib (required by @rollup/plugin-typescript)
    was not a dependency, and npm test could not start (jest-environment-jsdom was not installed, jest.config.js spelled
    moduleNameMapper as moduleNameMapping, and jest.setup.ts imported the environment package). Fixed, with twelve tests added
    (error mapping and retry rules, and the client's request headers through axios' real pipeline with a stubbed adapter).
    package.json's main pointed at dist/index.js, which the build never produces; it is now dist/index.cjs.js. A new
    "JavaScript SDK" CI job runs install, typecheck, test, build, a check that main/module/types exist and load, and
    npm audit --omit=dev. The package is not published to npm, so nothing needs republishing; its version follows the crate.
  • 0.6.0-rc107 -- zeroize 1.9.1 or later is now required, because earlier versions print Zeroizing secrets in {:?}. Zeroizing<T>
    hides its value from Debug only from zeroize 1.9.1 (2026-10-06); 1.9.0 and earlier derive Debug and print
    Zeroizing("<the secret>"). The manifest allowed 1.8.2, so a build whose lock resolved an older zeroize printed every
    Zeroizing<String> field of a type that derives or forwards Debug: configuration secrets (JwtConfig / SecurityConfig /
    SecureJwtConfig keys, RadiusConfig, StsConfig, CaepConfig, QuickStartAuth, OAuthProviderConfig), the email, SMS and
    Grafana API keys and passwords, OAuth 1.0a consumer and token secrets, hashed client secrets and the TOTP secret of an MFA
    method or setup response. The requirement is now zeroize = "1.9.1". Two tests pin it: one formats a Zeroizing holding a
    marker and checks the marker is absent, the other parses Cargo.toml and fails if the requirement is below 1.9.1 (forcing
    zeroize 1.8.2 in a scratch copy made the MFA redaction test fail with Zeroizing("...")).
  • 0.6.0-rc106 -- {:?} no longer prints a WS-Security password, OAuth authorization codes or refresh-token ids. Redacting Debug
    on UsernamePassword (value), EnhancedAuthorizationCode (code), RefreshToken (token_id) and UserCredentials
    (password_hash); EnhancedTokenStorage, whose maps are keyed by authorization code and refresh-token id, prints entry counts
    instead of its keys. These were missed by the name-based audit (value, code and token_id are not secret-sounding names).
    Serialize is unchanged. The MFA enrolment test now also pins that the Zeroizing<String> TOTP secret is not printed, so a
    change in the zeroize crate cannot expose it unnoticed, and each QR redaction was checked by making it raw (the test fails).
  • 0.6.0-rc105 -- distributed-rate-limiting (and any feature that enables the redis dependency without redis-storage) builds again on
    its own.
    storage::redis calls key_names::without_shadowed_legacy, which was gated on redis-storage instead of on the
    redis dependency that compiles the module, so --no-default-features --features distributed-rate-limiting failed with
    E0425. The CI feature matrix always pairs features with the storage backends, so nothing built that combination. A new
    "Feature Isolation" CI job runs cargo check --lib --no-default-features --features <f> for each explicitly declared
    feature, listed from cargo metadata so new features are covered automatically (it fails if the list is short or lacks
    distributed-rate-limiting). All 81 features, implicit ones included, were checked alone before this change and pass. Also:
    the JavaScript SDK's lockfile is refreshed within its semver ranges (follow-redirects 1.16.0 -> 1.16.1, superseding the
    Dependabot bump, and 40-odd other packages) and the SDK version follows the crate (0.6.0-rc105); npm audit --omit=dev
    reports 0 vulnerabilities. The SDK's own typecheck, test and build steps fail identically before and after (3 TypeScript
    errors, tslib and jest-environment-jsdom not installed): not changed here.
  • 0.6.0-rc101 -- {:?} of MFA enrolment material no longer prints backup codes or the TOTP URI. Lists of codes and
    QR / otpauth:// payloads (which embed the TOTP secret) were plain String / Vec<String> fields that the earlier
    redaction audit's name match and String-only type match missed. Now redacted: MfaSetupResponse (qr_code,
    backup_codes), BackupCodeVerifyRequest, UserBackupCodes (codes, used_codes), MfaMethodData (the TOTP QR URL and
    the backup-code list; its other variants print as before) and EnvironmentConfig.secrets. The audit now matches
    Vec<String> fields and the names backup_codes, recovery_codes, seed, mnemonic, qr_code, otpauth and
    *_codes; the device-flow QR code (public verification URI and user code, shown on screen) is allowlisted with a reason.
    Serialize is unchanged.
  • 0.6.0-rc99 -- {:?} of a value that carries an issued token no longer prints the token. rc96 redacted configuration and
    credential types; the request and response bodies that carry tokens still derived Debug over plain String fields, so a
    tracing call or panic that formatted one printed the token. Now redacted: the access, refresh and ID tokens of the OAuth
    TokenResponse, the CIBA, FAPI, token-exchange (both), JARM authorization, provider OAuthTokenResponse, IndieAuth and UMA
    responses and the API and Axum LoginResponse / RefreshResponse; the refresh, revoke, logout, introspection and
    verify-email request tokens; device codes in the device-authorization responses, token request and stored record; the step-up
    session token; RefreshRedemption's new refresh token; and AuthTokenBuilder. Each prints <redacted> (an unset optional
    token still prints None); Serialize is unchanged. The audit that fails the build for a derived Debug over a plain
    secret-named field now also matches these token names; the ACME challenge token (published by design) and the OAuth 1.0a
    token identifier are allowlisted with reasons.
  • 0.6.0-rc96 -- {:?} of a config or credential value no longer prints its secret. Thirty-odd types derived Debug
    over a plain String / Vec<u8> secret field, so a tracing call, dbg! or panic message that formatted one printed the
    secret: AuthConfig (secret), ClientConfig and its builder, the OAuth TokenRequest (client secret, password, code,
    refresh token, PKCE verifier, device code), ClientRegistrationResponse, IntrospectionClientCredentials,
    IntrospectRequest, the login, registration, create-user, change-password and MFA request bodies, CreateApiKeyResponse,
    the database and Basic-auth deployment settings, HTTP / OPA / SCIM client tokens, SMS provider credentials, the LDAP bind
    password, SAML, JARM, back-channel logout, token-exchange and WS-Security signing keys, threat-feed and Yubico keys, and
    stored certificates' private keys. Each now has a hand-written Debug that prints <redacted> for the secret (an unset
    optional secret still prints None). Fields held as Zeroizing<String> were already redacted. Serialize is unchanged:
    these values are still written out when a caller asks to save them. A new audit fails the build for any type that derives
    Debug with a plain secret-named field (its own fixtures show it flags a derived type, passes a hand-written one and
    ignores Zeroizing and hash fields), and a test prints each buildable type with a marker secret and checks it is absent,
    <redacted> is present and a non-secret field is still shown. Response bodies that carry issued tokens (TokenResponse
    and similar) still derive Debug.
  • 0.6.0-rc95 -- Storage settings that cannot be honoured are now refused instead of silently changed. AuthFramework::quick_start()
    with with_postgres, with_postgres_from_env, with_redis or with_redis_from_env used to log a warning and run on
    in-memory storage when the matching postgres-storage / redis-storage feature was off, so every key, session and token
    was lost on restart; build() now returns a configuration error. StorageConfig::Postgres { table_prefix } was accepted
    and ignored (the backend's table names are fixed), so two deployments given different prefixes shared tables;
    initialisation now returns a configuration error for any prefix other than the default "auth_". Each is a test that
    failed before and passes now (the Redis case with the feature off, the prefix case against PostgreSQL 15), each with a
    control that the supported setting still works.
  • 0.6.0-rc94 -- PostgresStorage stores and reads back a token's user_profile, permissions and roles (#93).
    AuthToken derives FromRow, so every field needs a column, but auth_tokens had none for these three: any
    token read failed with no column found for name: user_profile, and the values were never written. migrate()
    now adds the columns (ADD COLUMN IF NOT EXISTS, so existing tables upgrade in place; permissions and roles
    default to empty) and store_token / update_token write them. The test
    pg_token_round_trips_profile_roles_and_permissions failed 3 of 3 before and passes after, also on a table
    created with the old schema. All nine storage_postgres tests are no longer #[ignore]d and the --ignored
    Postgres CI step is removed; they run in the normal test step against CI's postgres service.
  • 0.6.0-rc93 -- PostgresStorage::migrate() is safe to call concurrently (#89). It ran its CREATE TABLE IF NOT EXISTS
    statements on separate pool connections, so two instances starting at once could both pass the existence
    check and collide on Postgres's own pg_type catalog (duplicate key value violates unique constraint "pg_type_typname_nsp_index"). The whole migration now runs in one transaction behind a transaction-scoped
    advisory lock. The regression test pg_concurrent_migrate_is_safe (8 concurrent callers on a fresh schema) failed
    12 of 12 runs before and passes 20 of 20 after, and is no longer #[ignore]d; the CI step no longer needs
    --test-threads=1.
  • 0.6.0-rc85 -- Production validation refuses a placeholder signing secret. AuthConfig::validate() in production
    mode now returns an error when the JWT secret is, or contains, a known template value (change-in-production,
    change-me, changeme, your-secret, development-only, replace-with; compared case-insensitively), however long and varied
    it looks: such a value is the same in every deployment that copied it. Other modes are unchanged.
  • 0.6.0-rc82 -- SamlAuthMethod::default() no longer signs tokens with a fixed secret. The default method used a secret
    spelled out in the source for its token manager, so a token signed with that value validated through any default
    SamlAuthMethod. It now generates a random secret per instance (its tokens verify only through that method's
    token_manager); build the method with SamlAuthMethod::new and your own TokenManager when tokens must verify
    elsewhere.
  • 0.6.0-rc81 -- The JWT introspection manager no longer signs with a fixed key. JwtIntrospectionManager::new signed
    and verified introspection responses with a key spelled out in the source, so a response claiming a token is active
    (with any scope) could be forged by anyone and accepted by verify_jwt_response. new now generates a random key per
    instance (responses verify only through that manager). New JwtIntrospectionManager::with_hmac_secret(config, secret)
    takes a shared secret of at least 32 bytes for deployments where issuer and verifier are different processes.
  • 0.6.0-rc74 -- The JARM manager no longer signs with a fixed development key. AdvancedJarmManager::new fell back to
    a key spelled out in the source when no RSA key was configured, and also when configured keys failed to parse, so
    responses could be signed (and accepted by validate_jarm_response) with a value that is the same in every deployment.
    With no key configured it now generates a random symmetric key for the instance (still warned about: its responses verify
    only inside that process); keys that are configured but do not parse are an error.
    Breaking: AdvancedJarmManager::new returns Result<Self> (it also no longer panics when the internal validator or
    HTTP client cannot be created).
  • 0.6.0-rc73 -- The default configuration generates a per-process signing secret. AppConfig::default() used a
    fixed placeholder as its JWT signing secret, so a configuration built from the defaults signed with a value that
    was the same in every deployment. It now generates a random 64-hex-digit secret once per process (all defaults in
    a process agree; no two processes share it). Tokens signed with it do not survive a restart: set JWT_SECRET or
    ConfigBuilder::with_jwt_secret for a stable key.

Changed

  • 0.6.0-rc166 -- tests/critical_security_detection.rs reads the files that hold the OAuth2 flow, and a missing file fails it (#18). Its seven tests searched src/server/oauth/oauth2.rs, a 159-line wrapper, for TODO: Validate client secret, user123 and a hardcoded scope list, and test_comprehensive_todo_audit skipped files that did not exist; a moved file read as no problem found. They now search oauth2.rs, oauth2_server.rs, api/oauth2.rs and api/oauth_advanced.rs (the oidc and client-registry files too for the TODO count), panic when a subject cannot be read, require that the subject mentions client_secret, and say only what the search shows. test_security_audit_effectiveness read only the top level of src/ and ignored a failed read; it walks the tree, fails on an unreadable directory and requires over 100 files. test_detectors_can_fail plants each pattern and a missing subject. Test-only. code_quality_audit.rs already fails on its findings; its secrets scan skipped a file it could not read and now fails on one. Part of #18.
  • 0.6.0-rc165 -- An authenticated OAuth2 client is rate limited by its own identity (#92). The router-wide limiter counts requests per caller address, so a client that rotates addresses was not slowed by it. /oauth/introspect and the authorization_code / refresh_token exchange at /oauth/token now also count each request from an authenticated confidential client against one bucket per client id (oauth_client:<client id>) of the same limiter the address buckets live in, with the same limits, whatever address it uses; over the limit: HTTP 429 rate_limit_exceeded at introspection, rate_limit_exceeded from the token endpoint. A failed authentication is not counted against the client it names, so nobody can exhaust another client's allowance with its client id. If the limiter cannot be consulted the request is refused (temporarily_unavailable), as the router-wide limiter does. /oauth/revoke is not covered here.
  • 0.6.0-rc164 -- /oauth/revoke acts only on tokens this server issued, and only for the client they were issued to (RFC 7009). It used to store a 30-day oauth2_revoked_token:<string> marker for any string a caller posted (up to 8 KiB), authenticated or not: 200 junk strings left 200 markers in storage. Now a string that is not a token this server issued (not one of its JWTs, not a stored refresh token) is a no-op that still answers success and stores nothing. A token issued to an OAuth client (a JWT with a client_id, or a stored refresh token) is revoked only by that client: a confidential client authenticates with HTTP Basic or client_id / client_secret in the body, a public client names itself with client_id; anything else is invalid_client and revokes nothing. A token with no client (a first-party session token) is still revoked by whoever holds it. Breaking: api::oauth2::revoke takes the request headers as a second argument, and RevokeRequest gains optional client_id and client_secret fields; a client that revokes its tokens through this endpoint must now identify itself.
  • 0.6.0-rc163 -- The OAuth2 introspection and revocation endpoints write to the audit trail (#91). /oauth/introspect records every request as a TokenIntrospected event (who asked: the client id; the result: active, inactive_revoked_or_unusable, inactive_invalid or client_authentication_failed; and the subject and jti when the token could be read) and /oauth/revoke records a TokenRevoked event (the token's fingerprint, and subject and jti for a JWT); no event holds a token or a secret. A storage failure while recording is logged and does not fail the request. New: AuditOperations::query_events(&AuditQuery) reads events back (not with AuditStorage::Tracing, which is write-only). Breaking: AuditEventType has a new variant, TokenIntrospected, so an exhaustive match over it must handle it. /oauth/revoke does not authenticate its caller, so its events name the actor as unauthenticated. The OAuth2 client_credentials grant is not served by the HTTP token endpoint, so there is nothing to record there.
  • 0.6.0-rc162 -- PostgreSQL list_user_sessions works on a database made by migrate() (#147). It selected last_activity, ip_address and user_agent, columns the sessions table (as migrate() creates it) does not have, so every call failed, and it rebuilt the session's data from the whole JSON object. It now reads the one data column, the whole SessionData that store_session wrote and get_session reads. The other sessions table definitions named in #147 (src/migrations/, docs/storage-backends.md) are not changed here: which one is canonical is still open.
  • 0.6.0-rc161 -- Storage backends no longer return expired tokens or sessions (#144). get_token, get_token_by_access_token and get_session on the SQLite, PostgreSQL, PersistAnt and in-memory (MemoryStorage, DashMapMemoryStorage) backends now return None for a token or session whose expires_at has passed, as the AuthStorage docs ask; before, only the in-memory sessions honoured it and the SQL backends pruned only in cleanup_expired. The framework's own validation already checked the presented token's expiry, so this was a storage-contract defect for code that calls storage directly, not a way past authentication. list_user_tokens and list_user_sessions are unchanged. Redis expires keys itself.
  • 0.6.0-rc160 -- Patent sweep corrected after a second read (docs/PATENT-SWEEP.md, docs/PATENT-SWEEP-CHARTS.md): wrong assignees and statuses fixed, the macaroon premise retracted (third-party caveats were removed in rc148), and the formerly unread patents recorded. Documentation only.
  • 0.6.0-rc159 -- A client without a secret can no longer authenticate to token introspection. /oauth/introspect compared the presented client secret with the stored one, and a client record with no secret, an empty one or a non-string one counted as "", so Basic client_id: or a POST body with an empty client_secret authenticated such a client. RFC 7662 section 2.1 requires the endpoint to demand authorization, so a client registered without a secret (a public client) now gets invalid_client (HTTP 401) there. Clients with a secret are unaffected; POST /oauth/register always issues one. An operator who relied on a public client calling introspection must give it a secret.
  • 0.6.0-rc158 -- OAuth2 client secrets are stored as hashes (#90). POST /oauth/register now stores client_secret_hash (Argon2id) in oauth2_client:{id} and no plain secret; the secret is still returned once in the registration response. A client record that still holds a plain client_secret keeps authenticating, and the first successful authentication of that client rewrites its record with the hash and without the plain secret (the record keeps its time to live); a wrong secret changes nothing. Existing plain secrets stay in storage until their client next authenticates: rotate the secrets of clients that rarely do, and treat backups taken before the upgrade as holding them. Authorization-code, refresh-token and introspection client authentication all go through the new api::client_secret module. The two other client registries named in #90 are not changed here.
  • 0.6.0-rc157 -- JARM JWE now authenticates its protected header (#99). The AES-256-GCM content encryption of a JARM JWE uses the base64url protected header as additional authenticated data, as RFC 7516 requires, on both encrypt and decrypt. A JWE made by an older version carries no such binding and no longer decrypts: there is no fallback to the old form, because accepting it would keep the header unauthenticated. JARM responses are short-lived, so a response issued before the upgrade is the only one affected. A standards-compliant JWE implementation on the other end now interoperates. Needs the rsa-private-key-ops feature.
  • 0.6.0-rc154 -- tools/advisory-ranges: regenerates the RustSec patched / unaffected lists so 0.6.0-rcN orders correctly. RustSec compares versions with semver, which orders rcN as text (rc101 < rc126 < rc41 < rc98); the tool turns the ranges we mean into exact =0.6.0-rcN entries and re-checks every entry. Development tooling only (/tools/ is excluded from the published crate).
  • 0.6.0-rc153 -- bergshamra 0.11 and kryptering 0.7 (SAML XML-DSig), still without the rsa crate.
  • 0.6.0-rc152 -- Security: GHSA-q4c2-8php-g7c8. Third-party caveats in protocols::macaroons were not verified in 0.6.0-rc41 through 0.6.0-rc138. 0.6.0-rc150 removed that API (see rc148) and is the fixed release. Documentation only; no code change. GHSA-q4c2-8php-g7c8
  • 0.6.0-rc151 -- The security audit test fails on a moved file. tests/final_comprehensive_security_audit.rs used to read a missing source file as "no problem found"; it now fails loudly, so a moved file cannot pass the audit silently (tests only; part of #18).
  • 0.6.0-rc150 -- First patent sweep recorded in docs/PATENT-SWEEP.md (first pass, not a clearance; its Needs review list is open).
  • 0.6.0-rc149 -- GNAP continue_transaction checks before it changes anything. The interaction hash is verified before the continuation token is rotated, so a request that fails the check leaves the holder's token usable, and it is compared with ct_eq. An unknown transaction, an expired one, a wrong token and a wrong interaction hash now return the same message (Invalid continuation request). New tests cover these, the token comparison, and the OAuth2 revoke log line.
  • 0.6.0-rc145 -- Two small hardening fixes. GNAP continuation-token comparison now uses subtle's ct_eq (equal-length slices are compared in full; a length mismatch returns early), and the OAuth2 revoke log line carries a token fingerprint instead of the token. The CodeRipper portfolio workflow is repinned to ee9c3950 (0.9.24).
  • 0.6.0-rc144 -- Spec-to-roadmap audit. docs/ROADMAP.md gains rows A1-A11 for work the docs promise and the roadmap did not carry; the stale "release 0.5.0 from rc18" priority now names 0.6.0. SECURITY.md no longer says WS-Federation extracts unverified JWT payloads or that OpenID4VP skips signature verification (the code does neither), and states the GNAP DPoP limitation. COMPATIBILITY.md SQLite row repaired. Documentation only.
  • 0.6.0-rc143 -- Two secret-scan findings are closed. tests/fixtures/test_private_key.pem, an unused RSA test key (committed in ee6dd8d2, made by the openssl genrsa command in the fixtures README; no source, test, workflow or doc references it), is deleted. examples/debug_server.rs reads its signing secret from AUTH_FRAMEWORK_DEBUG_SECRET and fails if it is unset, instead of carrying a literal. The docs/ROADMAP.md SEC-002 row is updated. History is not rewritten.
  • 0.6.0-rc142 -- auth-framework.png now has a provenance record. PROVENANCE.md states who made the README image, with what tool, and its terms, per the portfolio provenance rule. Documentation only; the image is unchanged.
  • 0.6.0-rc141 -- cargo fmt clean again. The rc138 shared-signals fix was not rustfmt-formatted, so the Check formatting step failed on main. Whitespace only in src/server/oidc/oidc_extensions.rs and tests/shared_signals_verification.rs; no behaviour change.
  • 0.6.0-rc138 -- Shared Signals event verification fails closed. SharedSignalsManager::receive_event now accepts an event only when it carries a signed token and SHARED_SIGNALS_VERIFICATION_KEY is configured; before, an event with no token, or (without the key variable) a token signed with a constant in the source, was accepted while verification was on. Callers that fed unsigned events with verification enabled now get an error. Includes the rc132 changes (member crates and SDKs carry the root version; rc132 was not published).
  • 0.6.0-rc132 -- CodeRipper findings are on the roadmap, one row per failing rule from the advisory portfolio job. Member crates and SDKs join the version: auth-storage-sqlite, sdks/python and sdks/javascript now carry the root version, held there by tests/member_versions_match_cargo.rs. No library change.
  • 0.6.0-rc131 -- Open issues are on the roadmap, and oauth2_test_server needs api-server. docs/ROADMAP.md gains a "Tracked Issues" table (size, priority, state measured at main) for the open GitHub issues; the oauth2_test_server binary, the debug_server example and five tests that import auth_framework::api now declare required-features = ["api-server"], so cargo check --no-default-features --all-targets builds (#282).
  • 0.6.0-rc130 -- CI: advisory CodeRipper portfolio check (portfolio job, pinned reusable workflow). Non-blocking; not a required check. No library change.
  • 0.6.0-rc129 -- serde-saphyr 2.0 (was 1.3). No API change for our YAML reads and writes (config manager, deployment config, threat-intelligence config, migration analyzer, admin CLI); the YAML config round-trip test passes unchanged.
  • 0.6.0-rc128 -- html-escape 0.3.0 (was 0.2). No API change; the admin web GUI's and the OIDC form-post response's escaping is unchanged, pinned by a known-answer test recorded under 0.2.15.
  • 0.6.0-rc127 -- quick-xml 0.42.0 (was 0.41). The SAML parsing code moves to its &str API (names, attribute values and text are &str now). Behaviour is unchanged; known-answer tests cover carriage returns (character reference and raw line ends) in signed assertions.
  • 0.6.0-rc122 -- pasetors 0.8.1 (was 0.7.7). No API change for PasetoLocalManager; a known-answer test confirms a v4.local token minted by 0.7.7 still decrypts.
  • 0.6.0-rc115 -- Dependencies: passkey and passkey-client 0.5 -> 0.6. The new release asks the caller for a crypto backend (Authenticator::new(.., RustCryptoBackend))
    and no longer exports passkey::types::rand; the passkey method now passes RustCryptoBackend and draws its 32-byte challenge from ring's system
    generator. The lock also moves the RustCrypto elliptic-curve stack that passkey uses (p256 0.14, ecdsa 0.17, elliptic-curve 0.14, crypto-bigint
    0.7, sec1, base16ct 1.0) and adds ed25519-dalek 3 and ml-dsa. No public item of this crate changes and no stored or wire format moves: a credential
    and an assertion recorded with passkey 0.5.0 (tests/fixtures/passkey_wire_format.json) are checked against the same shape and verified with ring alone,
    and the test passes on 0.5.0 and on 0.6.0. Known issue, not caused by this change: register_passkey reads the public key from a JSON path
    that the serialized credential does not have (authenticatorData is bytes), so the stored key is null on both releases; tracked separately.
  • 0.6.0-rc110 -- CI: a weekly cargo audit + cargo deny run on every maintained branch. A new RustSec advisory reaches a release branch's
    lockfile the day it is published, but lockfile refreshes land on main alone, so release/0.4.x and release/0.5.x went red
    (RUSTSEC-2026-0381/-0382/-0383) only when an unrelated PR ran CI there. The new workflow security-audit-branches.yml lists main
    and release/* from the remote each Monday (and on demand), then runs the same two checks as the "Security Audit" jobs against each
    branch; the list step fails if it is empty or lacks main, so the audit cannot pass vacuously. No code change.
  • 0.6.0-rc109 -- The derived-Debug audit is now a reviewed whitelist instead of a list of secret-sounding names, and about a hundred more types
    redact their Debug.
    The earlier audit failed the build only for fields whose names looked secret, so it missed value, code and
    token_id. tests/debug_field_review.txt now lists every plain-text field name of every Debug-deriving struct and enum in src/
    (about 680 types, 800 names) as public, secret-capable or ambiguous; a derived Debug over a secret-capable or ambiguous name fails
    unless the type redacts it by hand or has a per-type decision with a reason, and a name on no list fails, so each new field name needs a
    human decision. The scanner must find at least 900 Debug-deriving types and fails on seeded copies of the two leaks the old audit missed.
    Review of the secret-capable names redacted Debug on session ids, OAuth / OIDC / SAML / WebAuthn / CAS / GNAP / WS-Trust / Kerberos
    tokens, tickets and hints, SAML responses and assertions, MFA challenge codes, connection URLs and strings (StorageConfig,
    DatabaseConfig, RedisConfig, RateLimitConfig, QuickStartStorage), webhook URLs and headers (NotificationChannel,
    WebhookConfig), the unified-storage keys and values, generic database column strings, the admin session and CSRF tokens, and
    more; Serialize is unchanged. Tuple payloads (error messages, variant names) and public protocol values are recorded as allowed with a
    one-line reason. A behaviour test covers the enum payloads and connection strings.
  • 0.6.0-rc104 -- Dependencies: the RustCrypto digest family moves to its 0.11 / 0.13 generation (sha2 0.10 -> 0.11, sha1 0.10 ->
    0.11, md-5 0.10 -> 0.11, hmac 0.12 -> 0.13). No public item names these crates' types, and no wire or stored format changes:
    six known-answer tests were written first and pass on both generations (HMAC-SHA1 and HMAC-MD5 per RFC 2202, HMAC-SHA256 per
    RFC 4231 for the webhook signature and the emergency-bypass signature, the RADIUS MD5 response / accounting authenticators and
    Message-Authenticator HMAC, the WS-Security SHA-1 password digest, and the client-secret SHA-256 hash). Code changes are the new
    KeyInit import where an HMAC is built from a key and hex::encode in place of {:x} on a digest, which 0.11 no longer
    formats.
  • 0.6.0-rc103 -- BREAKING (dependency in the public API): jsonwebtoken 10.4 -> 11.1. Public items that take or return
    jsonwebtoken types (jsonwebtoken::Algorithm, DecodingKey, EncodingKey in security::secure_jwt and the JWT helpers)
    now use 11.1's, so a downstream crate that names them must move to jsonwebtoken 11 as well. Wire formats are unchanged:
    tokens signed under 10.4 verify under 11.1 (a hand-signed HS256 known-answer token is verified, and a one-character-tampered
    copy refused, on both versions) and the aws_lc_rs backend is kept, so rsa is still absent from the dependency graph.
    jsonwebtoken::Algorithm is now #[non_exhaustive], so the three matches over it each gained a fail-closed arm: key
    selection refuses an algorithm it does not know (AuthError::Configuration), the algorithm-strength classification rates it
    Weak, and the OIDC algorithm-name helper falls back to its Debug name. Tests added before the upgrade pin all twelve known
    algorithms in each of the three places.
  • 0.6.0-rc102 -- Dependencies: semver-compatible lockfile refresh (cargo update). 32 locked packages moved to their newest
    compatible release, among them tokio 1.54.0, actix-web 4.17.0 (with actix-http 3.19 and actix-server 2.10),
    role-system 1.3.0, rustls-webpki 0.103.16, crc32fast 1.5.3 and unicode-normalization 0.1.26. No manifest change and no
    source change.
  • 0.6.0-rc100 -- CI: cargo deny ignores RUSTSEC-2026-0374 (basic-toml unmaintained). The advisory (published 2026-09-26, no
    safe upgrade) turned the "Security Audit (deny)" job red. basic-toml is a build-time proc-macro dependency of askama_derive,
    and askama 0.16.1 -- the newest release -- still depends on it; only the web-gui feature enables askama, and nothing is linked
    into a built artifact. deny.toml now lists the advisory with that reason and a removal condition (askama dropping it). No code
    change.
  • 0.6.0-rc98 -- BREAKING: SMTP delivery is now behind an off-by-default smtp feature. lettre (with its pool, hostname
    and ring-based rustls TLS) was a mandatory dependency, so even a consumer that only needs tokens or TOTP compiled an SMTP
    stack. It is now optional; enable smtp to keep SMTP alert emails and SMTP-delivered MFA email. Without the feature the
    MFA SMTP provider returns a configuration error instead of sending, and an email alert channel logs that the alert was not
    sent (other channels are unaffected). The CI feature matrix builds with smtp.
  • 0.6.0-rc92 -- API key rotation is one atomic swap where the backend has one. On a backend with supports_atomic_swap()
    (memory, SQLite, PostgreSQL, Redis and the encrypted wrapper over them) rotate_api_key replaces the old key's rows by the
    new key's row in a single swap_kv, so after a crash or a race the old key or the new key is live, never both and never
    neither; of simultaneous rotations exactly one succeeds. Other backends keep the claim, inert-row and activate steps of the
    previous release (at most one live key, possibly none after a crash). The in-process memory backends are atomic with
    respect to other rotations but not to a reader running at the same instant. Tested with a crash before and after the swap
    on memory, SQLite, Redis 7 and PostgreSQL 15 (exactly one live key each time), and the eight-way race on all four;
    ignoring the swap result and disabling the swap path each fail the suites.
  • 0.6.0-rc88 -- Documentation. SECURITY.md listed 0.5.x as the only supported line and 0.4.x as unsupported, although 0.4.x is
    the newest stable line and receives the security fixes; it now lists 0.6.x, 0.5.x and 0.4.x as supported, says to use the newest
    release of your line, and names the releases that fixed the latest advisories (0.4.29, 0.5.0-rc51, 0.6.0-rc85). The README
    quickstart no longer tells readers to install 0.6.0-rc50, nor to run the 0.6.0-rc41 image: both fall inside a published
    advisory. It pins the current version, and the Docker line takes a <tag> to be filled from the package page, with a floor of
    0.6.0-rc85. New tests keep it so: README install pins may not be older than the first unaffected release, and SECURITY.md
    must mark every active line supported.
  • 0.6.0-rc87 -- Documentation. Installation references that could not work are fixed and guarded by a test. docs/web-frameworks.md
    depended on auth-framework 0.1.0 (yanked) with features actix-web, warp and rocket, none of which is a feature of this
    crate: it now names actix-integration and warp-integration, takes the newest release of your line, and says plainly that
    there is no Rocket integration. docs/guides/administrator-setup.md named the image authframework/auth-framework:0.4.0
    (a namespace this project does not publish to, at a yanked version): it now uses ghcr.io/ciresnave/auth-framework:<tag>.
    The README's development example no longer asks for a testing feature that does not exist.
  • 0.6.0-rc84 -- Code health, no behaviour change. Every allow(dead_code) now states why (reason = "...") and an audit test
    fails on one that does not. Removed the unused helpers behind five of them: the common_jwt::utils module (functions that
    read JWT claims without verifying the signature, used only by their own tests), KerberosManager::generate_response_token (private),
    the never-read created_at of the CIBA pending request and code/code_challenge_method of the IndieAuth stored code.
    Five allows that hid nothing were deleted, and the remaining ones (AwsSns configuration fields, an RFC 8628 response field,
    three Kerberos wire-layout structs, a Unix-only helper) carry their reason. key_names::without_shadowed_legacy is now
    compiled only with redis-storage, its only user, so a default build has no dead-code warning.
  • 0.6.0-rc83 -- Test only. The two crude code-quality audits are replaced by ones that can be asserted on, and are no
    longer #[ignore]d. The panic audit scans production code only: items under #[cfg(test)], #[cfg(all(test, ..))]
    and #[test] are skipped by a scanner that understands strings, raw strings, character literals and comments (and
    fails loudly if it loses track of an item). The hard-coded-secret audit matches the shapes a fixed credential has
    taken in this crate (secret-named literals, key constructors given a literal, "change me" markers, byte-string key
    constants) and lists each harmless hit with its reason; an unexplained hit, or a listed entry that no longer
    matches, fails the test. No library change.
  • 0.6.0-rc74 -- Test and CI only. The 16 Redis tests (storage_redis 6, kv_key_names_hold_no_credentials 9,
    storage_list_kv_keys 1) are no longer #[ignore]d. CI's --ignored step ran only storage_redis, so the
    key-name migration tests (a security property) and the list_kv_keys test never ran in CI; all passed when run
    once against Redis 7. They now run in the Test Suite and Coverage jobs, which provide a redis service and
    REDIS_URL, and fail without a server instead of being skipped. The Run ignored redis storage integration tests
    step is removed (it would run zero tests).
  • 0.6.0-rc72 -- DistributedRateLimiter::with_clock, a testing seam like RateLimiter::with_clock: the
    in-memory windows and penalties read the supplied clock instead of the system one (new is unchanged;
    the Redis path keeps the server's clock). The rate-limiting tests that slept past a window
    (sliding_window, with_penalty, window_reset, cleanup, token_bucket) now advance a fake clock:
    under load the real sleeps let the first request age out or the bucket refill early (4 of 24 loaded runs
    failed in a Linux container, 6 of 20 on Windows; 0 of 24 after).
  • 0.6.0-rc71 -- Test only. oidc_session_management::tests::test_session_validity no longer depends on the
    wall-clock second: it used a 1 s timeout and a real sleep, and failed whenever the second ticked over
    during create_session (19 of 60 runs in a Linux container; 0 of 60 after). No library change.
  • 0.6.0-rc70 -- CI hardening only, no library change. The eight postgres:15 / redis:7 service
    containers in ci-cd.yml are pinned by image digest, and every cargo build|test|check|clippy|bench
    in ci-cd.yml and release.yml (plus cargo install of cargo-audit/cross and cargo llvm-cov)
    now passes --locked, so CI builds the dependency set Cargo.lock describes. The cargo tree
    calls of the rsa-absence gate are left unlocked on purpose: its self-test crates have no lockfile.
  • 0.6.0-rc69 -- Extracting a downloaded .tar.gz feed is now size-limited. New
    ThreatIntelConfig::extraction_limits (ExtractionLimits: max_entries 10 000, max_entry_bytes
    512 MiB, max_total_bytes 1 GiB; absent in older configs, which get the defaults). The limits are
    checked against the sizes the archive declares, before an entry is written, so a few kilobytes of gzip
    can no longer expand to fill the disk; a refused archive removes the files it had already written and
    the error names the entry and the limit. Breaking for code that builds ThreatIntelConfig with a
    struct literal:
    add extraction_limits: Default::default().
  • 0.6.0-rc68 -- Comparison helpers hardened: SecureComparison::secure_string_compare,
    constant_time_eq, constant_time_eq_bytes and verify_token now examine the full length of both
    inputs. The wall-clock test_timing_attack_resistance is replaced by deterministic comparison tests.
  • 0.6.0-rc67 -- Removed the ZIP extraction path and the zip dependency. Nothing reached it: the
    threat-feed downloader extracts only a feed whose filename ends in .tar.gz, so a .zip feed was
    never selected, yet the dependency was non-optional and (in the 2.x line) unresolvable. A .zip path
    given to extract_compressed_feed is now refused with a "not supported" error and nothing is written,
    like .bz2 and .xz. Fifteen crates leave Cargo.lock and none arrive (including the C-backed
    bzip2-sys, lzma-sys, xz2 and zstd-sys). If ZIP feeds are wanted later, support re-enters with
    size limits and tests.
  • 0.6.0-rc66 -- zip 2 -> 9.0.4: a fresh resolve of the published rc releases failed. Every zip 2.x
    release is yanked on crates.io, and zip = "2" was a non-optional dependency, so a project without a
    lockfile entry could not add this crate. The zip project yanked the 2.x line with its security
    advisories (all rated high):
    GHSA-4684-h2q9-2vcp (ZipStreamReader::extract() / ZipArchive::extract() apply Unix permissions
    through symlinks without confining the target; fixed in 9.0.1), GHSA-4v9h-p7m9-rh26 (extract()
    creates symlinks whose targets lie outside the destination; fixed in 9.0.0), GHSA-vv5m-jxxh-4627
    (SUID/SGID bits preserved on extraction; fixed in 9.0.0) and CVE-2025-29787 / GHSA-94vh-gphv-8pm8
    (path canonicalization during extraction allows arbitrary file writes; fixed in 2.3.0, and our lockfile
    already had 2.4.2). Exposure, by reading the code: ThreatFeedManager::extract_zip never calls
    ZipArchive::extract, writes entries as regular files, creates no symlinks and sets no permissions,
    so the symlink and SUID advisories do not reach it; 9.0.4 also makes enclosed_name reject a
    separator inside one verbatim path component, which matters on Windows. Code change: zip 9 returns an
    entry's name as a Result (names need not be UTF-8), and it strips the root from an absolute name
    instead of refusing it, so extract_zip now refuses rooted names explicitly (/x, a backslash root,
    C:), as it did under zip 2. New zip_extraction_tests pin the Zip Slip paths (.., absolute,
    rooted spellings, symlink entries, a backslash inside a component); removing the enclosed_name
    guard turns two of them red, and an entry using a method this build cannot decode fails with an error
    naming the entry (Zip entry feed.csv (#0) cannot be read: compression method not supported: 12),
    leaving no partial file.
    Breaking for feeds: zip is now built with default-features = false, features = ["deflate"]:
    an extractor of untrusted archives should carry the least code. Archives whose entries use bzip2,
    zstd, lzma/xz, ppmd, deflate64 or AES encryption no longer extract (stored and deflate still do);
    the C-backed bzip2-sys, lzma-sys, xz2 and zstd-sys leave the
    dependency tree with 9 other crates, and only typed-path arrives. No deny.toml change. Not in this
    release: size limits on extracted archives (a separate change).
  • 0.6.0-rc65 -- Archive extraction hardened: entry types and destination confinement. The threat-feed
    downloader now extracts only regular files and directories from a .tar.gz feed, confines every
    write to the feeds directory, and refuses any other entry type with an error naming the entry.
  • 0.6.0-rc64 -- Dependency only. handlebars 4.7.9 -> 4.7.10 (dev dependency of the JavaScript SDK,
    sdks/javascript/package-lock.json; supersedes the stale Dependabot PR #128). No library change.
  • 0.6.0-rc63 -- AuthStorage::list_kv_keys no longer defaults to an empty list (#145). A backend
    that does not override it now gets an error instead of a silent empty key space (analytics,
    compliance, RBAC and maintenance listings read it). DashMapMemoryStorage, UnifiedStorage,
    storage::redis::RedisStorage, testing::utilities::MockStorage and the auth-storage-sqlite
    crate's SqliteStorage implement it (expired keys
    are not listed; the Redis one uses SCAN with the prefix matched literally). Breaking for
    out-of-tree AuthStorage implementations that relied on the default:
    implement
    list_kv_keys (see the trait documentation).
  • 0.6.0-rc62 -- Repository hygiene only. Adds .github/CODEOWNERS (* @ciresnave, WSP-001) and
    docs/glossary.md (DOC-009), sets top-level permissions: contents: read in ci-cd.yml and
    release.yml (SUP-008 excessive-permissions; jobs that need more already declare it), and
    records the waiver for the CI-only postgres:15/redis:7 service-container images
    (SUP-008 unpinned-images). No library or behaviour change.
  • 0.6.0-rc61 -- CI only. ci-cd.yml also runs on pushes to release/** branches, so the
    release lines get the same checks.
  • 0.6.0-rc60 -- CI only. The GitHub Actions in ci-cd.yml and release.yml are pinned to
    commit SHAs (43 moved, 11 kept); no library change.
  • 0.6.0-rc59 -- Dependency only. rustls 0.23.45 -> 0.23.46 in Cargo.lock, which fixes
    RUSTSEC-2026-0346, -0347, -0348 and -0349 (published 2026-10-14) that fail the advisory audit.
  • 0.6.0-rc58 -- Fixes to the rc57 key-name migration (API keys, Redis sessions, access-token
    mappings).
    Two concurrent validators migrating the same legacy API key no longer delete the
    row they share (only the reader that created the new row removes the legacy one, so a delete is
    always a revoke); revoke_api_key reads the legacy name before the new one so a migration in
    flight cannot hide the row from it; a reader that loses a migration race re-reads the new name
    instead of reporting the key missing; an expired legacy row is rejected and not copied; a Redis
    record read under the new name drops a legacy twin; a session stored under both names is counted
    once; and Redis reads no longer fail when the ACL denies scripting (the legacy row is served
    unmigrated).
  • 0.6.0-rc57 -- Storage key names changed (API keys, access-token mapping, sessions). API-key
    rows, the Redis access-token-to-token-id mapping and Redis session records are now named by a
    domain-separated SHA-256 digest of the credential (api_key_h:, access_token_h:/access_h:,
    session_h:) instead of the credential itself, so a key name never holds a usable credential.
    Rows written under the previous names keep working: each is moved to its new name, with its
    remaining TTL, the first time it is read, and revoke/delete remove both names. Upgrade
    note:
    upgrade every node that shares a Redis or database together (or drain old nodes first).
    A node still running the previous release neither sees rows written by an upgraded node nor
    keeps the rows an upgraded node has already moved. Backups taken before the upgrade still hold
    the previous key names, and keys that were readable from such a backup should be rotated.
    storage::redis::RedisStorage and storage::core::RedisStorage list sessions under both
    naming schemes while old rows remain.
  • 0.6.0-rc55 -- Documentation only. docs/STORAGE-AUDIT.md is re-verified against
    origin/main @ 3239b1c3 and corrected in place: encryption at rest is now described as wired
    by default (what remains open is listed), and the TOTP at-rest, PAR single-use, forwarded-header,
    MySQL and fixed-advisory claims no longer describe the audited ref a254de6b as current.
  • 0.6.0-rc54 -- Documentation and a guard only. The health-response example in
    docs/api/README.md carries the crate version and is now checked by
    openapi_version_matches_cargo together with the Current crate version line in
    docs/ROADMAP.md; the bug-report and question issue templates no longer suggest a specific
    version ("the version in your Cargo.lock"); .github/copilot-instructions.md no longer says
    "THE premier". docs/ROADMAP.md's "aims to become the premier ..." is an aspiration, not a
    claim, and is unchanged.
  • 0.6.0-rc53 -- Served value changed: the OpenAPI document the API serves
    (generate_openapi_spec(), info.version) reported the literal 0.5.0-rc24 on every release
    since rc24; it now reports the crate version (env!("CARGO_PKG_VERSION")), so it cannot drift.
    Consumers that read that field will see 0.6.0-rc53. The checked-in docs/api/openapi.yaml,
    openapi-modular.yaml, openapi-bundled.yaml and the two version examples in
    docs/api/schemas/health.yaml are static and now carry 0.6.0-rc53; a release bump must update
    them, and the new test openapi_version_matches_cargo fails the bump that forgets. Footers and
    titles that named 0.5.0-rc24 in migration-upgrade.md, production-guide.md,
    security-configuration.md and troubleshooting.md no longer name a version (and the
    "THE premier" line is gone). docs/development/TESTING_RESULTS.md is a recorded rc24
    snapshot and is unchanged.
  • 0.6.0-rc52 -- Documentation only. docs/guides/developer-integration.md pins 0.6.0-rc51 (it
    said 0.5.0-rc24) and no longer claims Axum integration and OIDC are default features (the
    defaults are enhanced-rbac and postgres-storage); docs/api/README.md example version;
    docs/guides/administrator-setup.md footer drops its stale version and superlative. README:
    the two present-tense "1,900+ passing tests" claims are replaced by a count measured from the
    0.6.0-rc50 package (2,588 #[test]/#[tokio::test] functions: 1,889 in src/, 699 in
    tests/) and its limits. The test readme_claims_are_true_of_the_artifact now also guards
    the developer-integration pins and that phrase.
  • 0.6.0-rc51 -- Documentation only, plus two small hardenings listed
    under Security. README: install pins and the Docker tag named 0.5.0-rc24, a version inside the
    affected range of published advisories, and are now on the 0.6.0 line (Docker: 0.6.0-rc41, the
    newest published image tag); the "CVE-Free" / "no critical vulnerabilities" / "SOC 2" /
    "military-grade" / "ONLY Rust" claims are removed or replaced with what is true; the "What's New"
    section says it stops at rc24 and points to this file. The two storage guides no longer name
    0.4.3 as the newest stable release (it is 0.4.7) or call the 0.6.0 line "0.5+"; they say the
    0.4.x line lacks new_with_storage. New test readme_claims_are_true_of_the_artifact.
    Other docs under docs/ still carry 0.5.0-rc24 pins and are not changed here.
  • 0.6.0-rc49 -- CI only: the Build and Test Docker Image job loads the test image into the
    Docker daemon (load: true) and smoke-tests it (toolchain and database client tools) instead
    of running a second full cargo test --all-features --release inside a container without
    Postgres or Redis. The job could never pass before (it ran docker run authframework:test
    on an image that was only in the build cache) and had been skipped because the audit jobs it
    depends on were red. No library change.

Security

  • 0.6.0-rc51 -- The marker that records an OAuth2 refresh token as
    redeemed now lives as long as the token (30 days) instead of 10 minutes (#117): if deleting the
    rotated-out record failed, the token became redeemable again once the marker expired. Behaviour
    change: each successful refresh now leaves that marker in storage for 30 days (it was 10
    minutes), and a refresh that fails after the claim but before rotation (access-token minting or
    storing the new refresh token) releases the marker so the client can retry. The buffer holding
    the unwrapped content-encryption key in JARM's RSA-OAEP JWE decryption is zeroized when dropped
    (#100); the AES key schedule aes-gcm derives from it is not.

  • 0.6.0-rc50 -- Breaking (custom storage backends): the default of AuthStorage::store_kv_if_absent is now an
    error instead of a non-atomic get_kv followed by store_kv. Single-use claims (refresh-token
    redemption, TOTP steps, WebAuthn sessions, PKCE codes) depend on it being a compare-and-set, and a
    backend that kept the old default silently lost that guarantee. A custom AuthStorage must
    implement store_kv_if_absent atomically (the in-tree memory, SQLite, PostgreSQL, Redis, unified
    and encrypted backends and MockStorage already do); until it does, the paths that need a claim
    fail with an error naming the method.

  • 0.6.0-rc48 -- The users:index list (admin user listing) is updated through one helper that
    serialises its read-modify-write within the process and reads each write back: simultaneous
    registrations (or a registration and a deletion) no longer drop each other's entry. A lost
    update was observed once in CI under coverage instrumentation (15 of 16 concurrent registrations
    reached the index). Across processes sharing one storage there is still no atomic
    compare-and-set on a stored value, so the read-back narrows that window rather than closing it.

  • 0.6.0-rc47 -- Dependencies: the two advisories that kept the Security Audit jobs red are gone from
    Cargo.lock by dependency changes, with no ignore-list entry. YAML now uses serde-saphyr
    (maintained, pure Rust) instead of serde_yaml 0.9 (deprecated, built on the unmaintained
    unsafe-libyaml, RUSTSEC-2024-0449). Breaking: the Yaml variants of AuthError and of
    deployment ConfigError now wrap serde_saphyr::Error instead of serde_yaml::Error, and YAML
    output from serde_saphyr::to_string can differ cosmetically from serde_yaml's (quoting,
    indentation). Parsing defaults differ too: duplicate mapping keys are an error, << merge keys
    are expanded, scalars follow YAML 1.2 rules (so yes/on and leading-zero numbers read
    differently from YAML 1.1), and parsing is budgeted (nesting depth, alias/anchor and node counts).
    The config show --format yaml CLI path reports a serialisation failure as AuthError::Config
    rather than AuthError::Yaml. actix-web is now depended on without its default features (only macros), so
    h2 0.3.x (RUSTSEC-2026-0258, no patched 0.3 release; actix-http requires h2 ^0.3.27) is no
    longer in this crate's graph for any feature combination; an application that enables
    actix-web's http2 feature itself takes h2 0.3.x with it (see SECURITY.md). cargo update
    brought the lock file to the latest semver-compatible versions (h2 0.4.20 among them);
    deny.toml no longer lists RUSTSEC-2026-0258 and bans any h2 < 0.4.16.

  • 0.6.0-rc46 -- SQLite store_kv / store_kv_if_absent: a TTL with a sub-second part no longer expires early (the expiry was truncated to whole seconds).

  • 0.6.0-rc46 -- Breaking (behaviour) summary of the token-hardening entries in this release:
    an OpenID Connect ID token is no longer accepted as an access token; OidcProvider::get_userinfo
    requires a live local account (federated subjects without a local record are refused there);
    a JWT client assertion must declare token_use: "client_assertion";
    AuthFramework::refresh_token(&AuthToken) renews only a token that is stored, valid, and equal
    to its stored record (its scopes and client binding come from the stored token), and
    validate_token additionally requires the value's client_id and user_id to match the JWT
    and the stored token; /oauth/introspect answers active: false for a deactivated or deleted
    local account and for ID tokens and other non-access JWTs (federated subjects without a local
    record are still reported as before); existing stored MFA lockout counters (mfa_failures:)
    are ignored after the upgrade (the new mfa_attempt:{user}:{slot} budget starts empty);
    WebAuthn completions claim their session atomically (its own change, listed here for
    visibility); deleting a user aborts if the deletion tombstone cannot be written.

  • 0.6.0-rc46 -- The JWT client assertion accepted by the token-introspection service
    (client_secret_jwt / private_key_jwt) must declare token_use: "client_assertion"
    (TokenManager::create_typed_jwt_token); before, any token signed by this server whose subject
    equalled the client id (an access, session or refresh token) authenticated the client. No
    in-repo code produced assertions, so this only affects integrators that built their own.
    WebAuthn registration and authentication completions claim their session with an atomic
    set-if-absent, so the same ceremony cannot complete twice concurrently.

  • 0.6.0-rc46 -- Deleting a user records deleted_user:{id}; JWT credentials passed to
    authenticate("jwt") (which allow federated subjects without a local record) are refused for
    an account that was deleted. Accounts deleted before the upgrade have no such record: their
    tokens expire normally.

  • 0.6.0-rc46 -- AuthFramework::validate_token (and the modular type's) requires the
    caller-supplied AuthToken value to agree with the signed JWT it carries (user_id must be
    the JWT subject), and AuthFramework::refresh_token(&AuthToken) renews only a token that
    validate_token accepts (live, stored, not revoked). Before, a forged value pairing a valid
    JWT with another user's id was accepted and could be refreshed into a token for that user.

  • 0.6.0-rc46 -- MFA attempt budget: an account gets 5 MFA code attempts per 15 minutes in total
    (across all its challenges), reserved BEFORE a code is checked with an atomic set-if-absent
    (mfa_attempt:{user}:{slot}), so a burst of parallel guesses can no longer exceed it. The
    counter mfa_failures:{user} is gone (a successful MFA login still clears the budget).

  • 0.6.0-rc46 -- OidcProvider::get_userinfo refuses a token whose account is deactivated,
    deleted or unreadable (it used to answer for any validly signed subject).

  • 0.6.0-rc46 -- A TOTP time step is claimed with the atomic store_kv_if_absent, so of
    simultaneous submissions of the same code exactly one is accepted (before, two parallel
    requests could both pass the read-then-write check).

  • 0.6.0-rc46 -- An OpenID Connect ID token (OidcProvider::create_id_token) now carries
    token_use: "id_token" and is no longer accepted as an access token: every access-token check
    (validate_access_jwt, validate_delegated_jwt, validate_api_token, get_userinfo, ...)
    refuses any JWT that declares a token_use other than being an access token. Before, an ID
    token was signed with the same key, issuer and audience as access tokens and so authenticated
    a request. New TokenManager::create_typed_jwt_token and JwtClaims::token_use. Access tokens
    still declare nothing (including those issued before the upgrade). Not changed: the
    SAML and passkey flows issue session tokens (not another token kind), so they are accepted as
    before.

  • 0.6.0-rc46 -- /oauth/introspect reports a token as active only if it could be used: the
    account must be live (deactivated or deleted accounts answer active: false), and a refresh
    JWT must be unspent and in an intact chain (it is reported with token_type: "refresh_token"
    instead of "Bearer").

  • 0.6.0-rc46 -- Breaking (behaviour): a token issued to an OAuth client is no longer a user
    session. The authorization-code and OAuth refresh grants now issue access tokens that carry
    the client_id claim (TokenManager::create_client_jwt_token /
    create_client_auth_token; AuthToken::client_id is set). validate_api_token (every
    built-in session endpoint: /users/*, /admin/*, /mfa/*, /auth/*, /webauthn/*,
    email verification, /oauth/authorize, dynamic client registration) and
    AuthFramework::validate_access_jwt (so the axum AuthenticatedUser extractor and
    auth_middleware, the actix, warp and unified paths, and authenticate("jwt")) refuse such
    a token. Before, a client registered for a single scope held a token that the API treated as
    the user's full session (roles come from the user record, not from the scope). /oauth/userinfo
    and /users/me accept client-bound tokens. Migration: integrators who protect routes
    meant for delegated (third-party) access should use the new opt-in: axum DelegatedUser
    (instead of AuthenticatedUser; user.0.token.client_id tells the route which client) or
    AuthFramework::validate_delegated_jwt; scope is advisory unless the route checks it.
    Access tokens issued before the upgrade carry no client_id and keep working until they
    expire (at most the token lifetime, 1 hour by default). Delegated routes must not
    also sit behind auth_middleware (it refuses client-bound tokens). The standalone
    OAuth2Server module now puts the client in the signed JWT of user-bound tokens, and
    TokenManager::refresh_token keeps a client-bound token client-bound. SDK users: a token
    from /oauth/token is a delegated token; it works at /oauth/userinfo and /users/me,
    not at the session endpoints. Not done: mapping scopes to permissions (a future design
    question).

  • 0.6.0-rc46 -- OAuth endpoints: the authorization-code and OAuth refresh-token grants claim
    their one-time marker with the atomic store_kv_if_absent, only for a code or token that
    exists (so simultaneous redemptions succeed once and made-up codes store nothing);
    /oauth/revoke rejects tokens over 8 KiB; end_session also records the revoked_token:{jti}
    marker the bearer paths read; /auth/logout with a refresh token also revokes that token's
    whole refresh chain. Known limits, unchanged: OAuth access tokens are ordinary user JWTs, so
    a client's registered scope limits what the token's scope string says, not which endpoints
    accept the token (roles come from the user record); /oauth/introspect reports a refresh
    JWT as active; tokens minted by the OIDC id-token, SAML and passkey flows are not
    distinguished from access tokens by a token_use claim; wrong-code accounting for MFA is a
    read-modify-write, so a burst of parallel guesses can exceed the 5-failure lockout (each
    challenge is still capped at 5 and a user keeps at most 3). The JavaScript SDK's
    TokenResponse gains refresh_token (refresh tokens are single-use: store the new one).

  • 0.6.0-rc46 -- Review follow-ups: rate-limit buckets for IPv6 clients are per /64; a WebAuthn
    origin must be exactly scheme://host[:port] (no credentials, path, query or fragment);
    WEBAUTHN_USER_VERIFICATION accepts the three values in any case and an unrecognised value
    now means required (with an error log) instead of silently preferred; the WebAuthn
    handlers read WebAuthnConfig::from_env() per request, so origins and the
    user-verification requirement are configured through the environment (the origins() /
    user_verification() builders only affect code that calls the helpers itself);
    WebAuthnConfig gains the public fields rp_origins and user_verification; the
    conditional-permission context no longer sets connection_type=vpn from a client-supplied
    Via header; a genuine corporate client behind a proxy that adds Via: ... proxy is
    assessed Proxy rather than Corporate;
    TokenExchangeManagerFactory::create_basic_manager_with_revocation installs the revocation
    list (the plain factory does not, and AdvancedTokenExchangeManager has no built-in
    revocation or token-type check).

  • 0.6.0-rc46 -- Sessions: rotate_on_privilege_escalation (the SessionConfig field and
    the builder method) is deprecated and documented as having no effect. It was true in every
    preset but nothing ever read it, so sessions were never rotated on privilege escalation; the
    crate has no escalation event to hook. Use rotate_periodically for rotation.

  • 0.6.0-rc46 -- Sessions: SessionManager::revoke_all_user_sessions now revokes every session
    that is not already expired or revoked (it used to revoke only Active ones, leaving
    sessions awaiting MFA, re-authentication or rotation, suspended and high-risk sessions
    usable after a "log out everywhere").

  • 0.6.0-rc46 -- Authorization context connection type: the assessed connection type no
    longer depends on whether the client sent a Via header. Network membership
    (corporate_networks) and "direct" are decided from the peer address, the Tor user-agent
    check always runs, and a Via header can only raise the assessed risk (VPN/proxy). Before,
    a request without Via was always Unknown (+10 risk) and one with an arbitrary Via
    could be assessed Direct or Corporate (-10). ContextBuilder::from_api_state shares the
    server's trusted-proxy list. Known, unchanged: login_risk_level in the API login response
    counts raw X-Forwarded-For hops (advisory output only; a client can only raise its own
    score).

  • 0.6.0-rc46 -- WebAuthn: the ceremony origin must now equal a configured origin in scheme,
    host AND port (it used to compare only the host). Configure origins with
    WebAuthnConfig::origins / WEBAUTHN_ORIGINS (comma separated); the default is
    https://{rp_id} (plus http://localhost for the localhost rp id), so a deployment served
    on a non-default port or from other hostnames must set them. Assertions are checked per
    WebAuthn section 7.2: rpIdHash must be SHA-256 of the rp id and the user-presence flag must
    be set, and the user-verification flag when WEBAUTHN_USER_VERIFICATION=required.
    The user-verification requirement now comes from server configuration (default preferred),
    not from the request body. Registration does not parse the attestation object, so its
    authenticator data flags are not checked (only attestation none is accepted).

  • 0.6.0-rc46 -- AuthStorage::store_kv_if_absent (new, with a non-atomic default) is an atomic
    compare-and-set in the memory, SQLite, PostgreSQL, Redis and encrypted-wrapper backends
    (UnifiedStorage and auth-storage-sqlite too; the test-only MockStorage keeps the default). Refresh-token redemption uses it, so of any number of
    simultaneous redemptions of one refresh token exactly one succeeds; custom AuthStorage
    implementations should override it.

  • 0.6.0-rc46 -- The axum integration's login route (AuthRouter, POST /auth/login) now
    authenticates the posted username and password against the user store (the password
    method, as the API server's login does) and answers 401 for unknown users, wrong passwords
    and accounts that require a second factor (this route cannot complete one). It used to issue
    a token for whatever username was posted when an authentication method named jwt was
    registered, and failed with a 400 otherwise. It does not apply the API server's per-account
    lockout.

  • 0.6.0-rc46 -- Token exchange refuses refresh tokens presented as subject/actor tokens under
    either typ spelling the crate mints (refresh and refresh_token; one shared helper,
    security::secure_jwt::is_refresh_typ, and constants used by the FAPI and CIBA minters).
    Access tokens issued from a refresh chain stay valid until they expire even after the
    chain is revoked (reuse detection ends the chain, not access tokens already issued).

  • 0.6.0-rc46 -- Authorization context client address: the address that authorization_enhanced
    policies see (AuthorizationContext::ip_address and the ip_address entry of the request
    context) is now the connection's peer address, or, when the peer is listed in
    ContextBuilder::with_trusted_proxies / ApiState::trusted_proxies, the client that proxy
    forwarded for (rightmost X-Forwarded-For entry that is not itself a trusted proxy). A raw
    X-Forwarded-For / X-Real-IP is never copied into the context any more, and a request
    with no peer address has no client address. Serve the router with
    into_make_service_with_connect_info::<SocketAddr>(). Behind a reverse proxy, list the proxy
    with ApiServer::with_trusted_proxies (the authorization middleware then uses the same list;
    build a stand-alone ContextBuilder with ContextBuilder::from_api_state), and do not put
    the proxy's own address in corporate_networks or an IpWhitelist: an
    unlisted proxy makes every client look like the proxy. ip_address is now always a single
    canonical IP (it used to be the raw header, e.g. a, b, c) and is absent without a peer
    address; a ConnectInfo<IpAddr> inserted by the integrator is no longer read.

  • 0.6.0-rc46 -- Refresh tokens are single-use and tokens of deactivated accounts stop
    working. POST /auth/refresh (and the axum integration's refresh route) returns the next
    refresh token with the new access token; the redeemed token is spent, and presenting an
    already-redeemed token is refused and revokes the rest of its chain (refresh_token is a
    new field of RefreshResponse; clients must store the new token). The axum integration's
    refresh route now accepts only a refresh token (it used to mint a new access token from an
    access token) and its login route issues one. AuthFramework::redeem_refresh_jwt is the
    shared implementation. validate_access_jwt also refuses tokens of a deactivated account
    (so the axum, actix, warp and unified paths do, not only validate_api_token);
    AuthFramework::validate_token (and the modular type's) refuses tokens on the revocation
    list and stored-revoked tokens; OIDC get_userinfo reads the revocation list;
    TokenExchangeManager::with_revocation_store makes token exchange refuse revoked tokens, and
    subject/actor tokens that are refresh tokens unless declared as such (opt-in: the bundled
    factories do not install a store, so install one when you build the manager yourself). A
    token whose subject has no account record (deleted account, or a subject never registered
    here) is now refused by validate_access_jwt and redeem_refresh_jwt, as
    validate_api_token already did; JWT credentials passed to authenticate (federated
    OIDC/OAuth2 subjects) may still have no local record, but a deactivated local record is
    refused there too. POST /auth/refresh answers INVALID_TOKEN for a
    deactivated account (it used to answer ACCOUNT_DEACTIVATED). Known limits: access tokens already issued stay valid until they expire even after their
    chain is revoked. Logout keeps a refresh token's revocation for the token's remaining life
    (it was 7 days).

  • 0.6.0-rc46 -- Bearer JWT validation: AuthFramework::validate_access_jwt is now the one
    check for a JWT presented as an access token (signature and claims, not a refresh token,
    not revoked; a revocation list that cannot be read rejects the token). It backs
    validate_api_token, the axum auth_middleware / AuthenticatedUser / refresh handler,
    the actix middleware and permission checks, the warp filter, the unified validator,
    OAuth userinfo, and JWT credentials passed to authenticate. Tokens revoked by logout or
    revocation, and refresh JWTs, are therefore refused on those paths (previously only
    validate_api_token refused revoked tokens). AuthFramework::revoke_token now also writes
    the revocation marker. Introspection answers active: false when the revocation list
    cannot be read. Rejections are AuthError::Token(Invalid) (HTTP 401 in the axum
    integration) instead of AuthError::Unauthorized. Cost: one or two extra storage reads
    per authenticated request (the revocation list, and the account record on the integration
    paths); custom AuthStorage implementations must return Ok(None) for a missing key.
    AuthFramework::validate_token and the modular type's now also read the revocation list
    (see the later entries); they still validate the caller's AuthToken value rather than the
    JWT's own claims, and AuthFramework::refresh_token(&AuthToken) still builds its result
    from the caller's value.

  • 0.6.0-rc46 -- MFA: an MFA challenge now allows a limited number of attempts
    (max_attempts, 5 for login challenges); each attempt is counted before the code is
    checked and an exhausted challenge is removed. Wrong codes submitted at /auth/login are
    counted against the account the challenge belongs to (whatever username is sent) and, after
    5 failures within 15 minutes, further codes are refused and no new challenge is issued
    (ACCOUNT_LOCKED) until the window passes; a successful MFA login clears the count. A TOTP
    code is accepted once: a code for a time step that is not newer than the last accepted one
    is refused. complete_mfa now signs in the user of the STORED challenge rather than the
    user_id of the challenge value the caller passes. A user keeps at most 3 pending
    challenges (a new one retires the oldest). /auth/login answers ACCOUNT_LOCKED instead
    of issuing an MFA challenge while the lockout is active. The one-use rule also applies to
    TotpManager::verify_code callers: a code already accepted for a time step is refused the
    second time. AuthFramework (not the modular type) gains mfa_challenge_owner. Library
    entry points that verify a single factor outside a challenge (verify_sms_code,
    verify_totp_code, the step-up helpers) keep their own, uncapped, behaviour: apply your
    own limits if you expose them.

  • 0.6.0-rc46 -- Rate limiting: the request rate limiter now identifies a client by the
    connection's peer address; X-Forwarded-For / X-Real-IP are believed only when the
    peer is listed in the new ApiServer::with_trusted_proxies (default: none), using the
    rightmost entry that is not itself a trusted proxy. ApiServer::start serves with
    connect-info. Action required if you serve build_router() yourself: use
    into_make_service_with_connect_info::<SocketAddr>(); without a peer address all
    requests share one bucket. If a reverse proxy sits in front of this server: list its
    address with with_trusted_proxies, otherwise every client appears as the proxy.
    ApiState gains a trusted_proxies field. Every trusted proxy must APPEND to
    X-Forwarded-For (not pass the client's value through); X-Real-IP is not consulted;
    proxies are listed by exact address (no CIDR).

  • 0.6.0-rc46 -- OAuth2 scopes and refresh tokens: a client can be granted only the scopes
    it is registered for. /oauth/authorize refuses a request for any other scope with
    invalid_scope; with no scope requested the client receives its registered scopes (a
    client registered without scope information keeps the previous defaults: openid profile email). The token endpoint clamps the scope of a code to the client's registration and
    refuses codes for unregistered clients (invalid_client). Refresh tokens now carry a
    token_use: "refresh" claim: /auth/refresh accepts only such tokens (the scope string
    is no longer consulted), and a refresh token is not accepted as an access token by
    validate_api_token. Breaking: refresh tokens issued before this change are not
    accepted; users sign in again. Clients that relied on receiving scopes beyond their
    registration must have those scopes registered; a registration that names an empty scope
    grants nothing. The refresh grant enforces the current registration too. The axum, actix
    and warp integration extractors are covered separately.

  • 0.6.0-rc46 -- Email verification: the verification token is delivered only through the
    new EmailVerificationSender trait (install one with
    ApiState::with_email_verification_sender) and is no longer part of any HTTP response.
    POST /auth/resend-verification now returns one identical response for every address
    (unknown, pending, already verified, delivery failed). Breaking: the
    verification_token field is removed from VerificationSentResponse; ApiState gains
    an email_verification_sender field (use ApiState::new + the builder). Without a
    sender installed, no message can be delivered and email verification cannot complete
    (ApiServer::with_email_verification_sender installs one for the built-in router).
    A token is now bound to the address it was mailed to and verifies only that address;
    changing the address on the profile clears email_verified. Tokens issued before this
    change are not accepted (request a new one).

  • Fixed GHSA-cw9x-gg6m-9cqr (critical): the WebAuthn/passkey registration and
    sign-in endpoints did not require authentication. An unauthenticated caller
    could register a credential of their own for any username and then sign in
    with it, receiving a token whose subject was a caller-chosen string; because
    token validation accepted any validly signed subject, that token worked
    against every bearer-protected endpoint, and against an existing account
    whenever the attacker knew its account id. All users of 0.5.0-rc18 through
    0.5.0-rc26 and of every 0.6.0 pre-release before 0.6.0-rc41 should upgrade;
    0.4.x is not affected (it has no WebAuthn HTTP endpoints). Registration now
    requires a bearer token for the target account (or an admin) and the same
    token must complete it; sign-in resolves the username to the real account
    and requires it to exist and be active before issuing a token; bearer
    validation rejects a subject that has no user record; and a client-data
    origin is mandatory. Passkeys registered before this release are
    ignored: re-register them (they could have been planted).
    Breaking, by necessity:

    • passkeys are stored per account id instead of per username, so all existing
      passkeys stop working until re-registered;
    • sign-in without a username (the discoverable-credential flow) is no longer
      supported;
    • the sign-in response user_id and the token sub are the account id, not
      the username;
    • POST /webauthn/registration/init and /complete require
      Authorization: Bearer ...;
    • the default WEBAUTHN_ATTESTATION is none, and any other value makes
      registration refuse (the server cannot verify attestation statements);
    • tokens whose sub has no user record are rejected by every bearer-protected
      endpoint.
  • 0.6.0-rc46 -- OAuth2 authorization_code grant (#140): the token endpoint never verified
    the client secret. A confidential client (its registered record holds a non-empty
    client_secret) can now redeem a code only by presenting that secret; missing, empty
    or wrong is invalid_client. The client must be registered (an unregistered
    client_id is invalid_client). Whether a client is public is now decided by its
    registration, not by whether the request carries a secret, so a public client can no
    longer skip PKCE by sending an arbitrary secret. A code issued to a different client
    is invalid_grant, and neither that refusal nor a failed client authentication
    consumes the code (previously the code was deleted before the client_id comparison,
    so presenting someone else's code destroyed it). Before this fix, anyone holding a
    valid authorization code and its client_id could redeem it without the secret
    (PKCE still applied where the code carried a challenge). Behaviour change:
    integrators that redeemed codes for unregistered client ids must register them.

  • 0.6.0-rc40 -- OAuth2 refresh grant (#118): a refresh token that carries a client_id is now
    redeemable ONLY by that client (RFC 6749 section 6). A confidential client (its
    registered record holds a client_secret) must authenticate with client_id +
    client_secret, and the authenticated client must be the one the token was
    issued to: missing or wrong credentials are invalid_client; a different,
    validly authenticated client is invalid_grant. A public client must send a
    matching client_id; missing or different is invalid_grant. There is no
    "check only if present" path, a token bound to a client that is no longer
    registered fails closed, and a refused attempt does not consume the token.
    Tokens issued without any client (older versions) are unchanged. "Confidential"
    means the registered record holds a non-empty client_secret (the same test
    /oauth/authorize uses); dynamically registered clients always receive a
    secret, so they are confidential. Credentials are read from the JSON request
    body (this endpoint has no HTTP Basic parsing). Behaviour change:
    integrators that refreshed without client credentials must now send them.

  • 0.6.0-rc38 -- startup checks for storage encryption. (1) Storage supplied
    through new_with_storage / replace_storage / the builder's
    custom_storage bypasses the factory's automatic wrapping, so with
    storage_encryption.enabled (the default) and a storage that is not an
    EncryptedStorage KV values were silently NOT encrypted at rest;
    initialize() now logs a warning, and fails when the new
    storage_encryption.require_wrapped_storage is true (new
    AuthStorage::encrypts_kv_at_rest, default false, true for
    EncryptedStorage). (2) New opt-in
    AUTH_STORAGE_ENCRYPTION_STRICT_KEY_FILE_PERMISSIONS=1 turns the
    group/other-readable keys-file warning into a startup error (Unix).
    (3) The single AUTH_STORAGE_ENCRYPTION_KEY env var cannot rotate; this is
    now documented and the storage factory warns when it protects data that is
    already encrypted. Breaking: StorageEncryptionConfig gains
    require_wrapped_storage (struct literals need ..Default::default()).

  • 0.6.0-rc33 -- storage-encryption migration keeps KV TTLs. security encrypt-kv /
    migrate_kv_to_encrypted used to re-store every value with no TTL, which
    silently made expiring entries (one-time codes, rate-limit windows,
    expiring API keys) permanent. They now keep the remaining TTL on every
    backend that reports it (new AuthStorage::tracks_kv_ttl; all built-in
    backends do). A real run on a backend that cannot report TTLs is refused
    unless MigrationOptions::accept_ttl_loss / --accept-ttl-loss is given,
    and a backup on such a backend logs a warning. Breaking:
    MigrationOptions gains the accept_ttl_loss field.

  • 0.6.0-rc24 -- storage encryption envelope format v2 (storage-format-affecting):
    the AAD now binds the envelope's own
    v, algorithm and key_id as well as the record's storage key, so
    relabelling a key id or rewriting v to downgrade an envelope fails
    authentication. Formats v0 and v1 are legacy: readable only behind
    storage_encryption.allow_legacy_v0 (the name is historical; it now covers
    both) and upgraded to v2 by security encrypt-kv --accept-legacy-v0.
    A stored value only counts as an envelope if it names the right algorithm
    and, from v1 on, carries a non-empty key_id, so plaintext JSON that
    resembles an envelope is no longer misread. Every decryption failure now
    returns the same error text (Decryption failed; the cause is logged),
    legacy trial decryption runs in a stable key order, and safe_log_id
    keeps 8 hash bytes instead of 4. No release has shipped encrypted storage,
    so there is no deployed v1 data to migrate; Breaking only for
    pre-release testers, who must run the migration tool or set
    allow_legacy_v0. Maintenance snapshots sealed by 0.6.0-rc21 (format v1)
    stay restorable regardless of allow_legacy_v0.

  • 0.6.0-rc21 -- maintenance backup/restore (#121), storage-format-affecting (backups are now sealed): a snapshot file no longer holds
    recoverable plaintext. When the framework encrypts at rest (the default
    for every persistent backend), the whole snapshot file -- KV secrets,
    access/refresh tokens, sessions, user emails -- is sealed under the
    storage encryption key (AES-256-GCM, snapshot-specific AAD); restore
    opens it before resetting anything, so a wrong or missing key leaves
    live data untouched. In-memory storage and
    storage_encryption.enabled = false still write plaintext snapshots.
    Restore also preserves KV TTLs now (it used to make every entry
    permanent) via a new AuthStorage::get_kv_ttl method (default: None,
    i.e. TTL unknown); the bundled memory, Redis, SQLite and PostgreSQL
    backends override it. Custom backends should too. Plaintext snapshots
    written before this change still restore where no sealing applies, but a
    framework that seals refuses to restore an unsealed snapshot (the
    checksum inside the file is unkeyed, so sealing is what authenticates
    it). Sealing follows config.storage like the storage factory: storage
    supplied through new_with_storage / replace_storage /
    custom_storage while the config still says Memory gets an unsealed
    snapshot.

  • Fixed GHSA-jfmh-j245-wpjg: MFA verification no longer derives a
    fallback secret.

  • Fixed GHSA-rf2h-7m78-hhjm: revoking a refresh token now invalidates
    it.

  • RSA support (jsonwebtoken's RS*/PS* JWT signing/verification, RSA JWKS
    export via the rsa-verify feature, and JARM's RSA-OAEP JWE via
    rsa-private-key-ops) no longer depends on the rsa crate
    (RUSTSEC-2023-0071, an unpatched RSA timing sidechannel) -- backed by
    aws-lc-rs instead. Behavior change: aws-lc-rs enforces a 2048-8192
    bit RSA key size range; a key outside that range that was previously
    accepted (e.g. a 1024-bit key) is now rejected. This is a hardening
    change, not a regression -- 1024-bit RSA has been considered
    insufficiently secure for new deployments for years -- but it is a real
    behavior change for any integrator whose external IdP or JARM
    recipient key falls outside the new range. mysql-storage depended
    on rsa via a separate path (sqlx-mysql's caching_sha2_password
    auth plugin) -- see the mysql-storage removal entry below, which
    closes this.

  • SAML (saml feature) no longer depends on the rsa crate either:
    bergshamra is bumped 0.3.1 -> 0.9.2 and switched from its default
    rustcrypto backend to its aws-lc backend (via a new direct
    kryptering dependency), so IdP signing-key verification now goes
    through aws-lc-rs for RSA, P-256, and P-384 keys instead of the
    rsa/p256/p384 RustCrypto crates. KeyData construction changed
    from bergshamra's old public enum (KeyData::Rsa{..},
    KeyData::EcP256{..}, KeyData::EcP384{..}) to the new opaque
    KeyData::from_spki_der(kryptering::KeyAlgorithm, der) API.
    Behavior changes, found by adversarial review (not just an internal
    implementation detail):

    • the same 2048-bit RSA key-size floor from the jsonwebtoken/JARM
      change above now applies to SAML IdP signing keys too -- a
      previously-accepted sub-2048-bit RSA IdP certificate now fails SAML
      signature verification at login time, not at startup;
    • the aws-lc backend drops several weaker signature algorithms that
      bergshamra's default rustcrypto backend (and its optional
      legacy-algorithms feature, not enabled here) supported: RSA-SHA224,
      RSA-PSS with SHA1/SHA224/SHA3-, ECDSA with SHA1/SHA224/SHA3-, and
      (via legacy-algorithms specifically) DSA, MD5, and RIPEMD160.
      RSA-SHA1 -- the one legacy combination real-world SAML IdPs still
      sometimes use -- is unaffected and continues to work.
    • Breaking: saml no longer builds on Windows. kryptering's
      aws-lc feature only compiles on Linux and non-FIPS macOS
      (x86_64/aarch64); it hard-errors via compile_error! on Windows and
      other platforms (kryptering-0.6.0/src/lib.rs). CI (ubuntu-only)
      does not catch this. CireSnave's ruling (2026-10-04): accept this
      loss for now -- we are actively searching for a way to support
      SAML on Windows again
      ; see
      #106 for
      the options being tracked (an upstream fix to kryptering, bypassing
      it for just this one code path, or a platform-conditional dependency
      split).
  • Breaking: mysql-storage has been removed entirely. Per
    CireSnave: "Drop MySQL. I'm not a huge fan of Oracle products
    anyway." This was the last remaining path to the rsa crate
    (RUSTSEC-2023-0071) via sqlx-mysql's caching_sha2_password
    auth-plugin dependency. MySqlStorage, StorageConfig::MySQL, the
    mysql config builder method, and MySqlMigrationManager are all
    gone; a mysql:// DATABASE_URL now returns a clear config error
    instead of silently requiring a feature that no longer exists.
    Migration: use postgres-storage, sqlite-storage, or
    redis-storage instead. If MySQL support is a hard requirement, pin
    to the last version that had it: 0.4.4 (stable) or 0.5.0-rc25
    (release candidate) -- neither will receive further updates.
    Separately found and fixed while verifying rsa is now absent from
    every default-feature build: the openid-connect feature's
    openidconnect crate has its own hard, non-optional dependency on
    rsa, unrelated to MySQL -- tracked as
    #113, not
    fixed by this change. A new CI job ("Security Audit (rsa absence)")
    now asserts rsa is absent from every shipped feature combination
    except saml and openid-connect, which have their own tracked
    exceptions (deny.toml documents both).

  • Breaking: each storage-backend feature now enables only its own
    sqlx sub-feature -- postgres-storage -> sqlx/postgres,
    mysql-storage -> sqlx/mysql, sqlite-storage -> sqlx/sqlite.
    Previously a single sqlx dependency declaration listed all three
    sub-features unconditionally, so enabling any one storage backend
    (including postgres-storage, which is in default) silently built
    sqlx with full MySQL support too, and therefore pulled in the rsa
    crate (RUSTSEC-2023-0071, see above) via sqlx-mysql's
    caching_sha2_password auth-plugin dependency -- regardless of
    whether mysql-storage was ever requested. Combined with the RSA
    backend switch above, default builds (and postgres-storage-only,
    sqlite-storage-only builds) no longer depend on rsa at all;
    mysql-storage is now the only storage feature that still does,
    isolated to that one feature rather than spread across every build
    that touches sqlx.

  • Breaking: create_client_assertion (RFC 7523 private-key-JWT client
    authentication) now takes encoding_key: &EncodingKey instead of raw
    signing-key bytes (signing_key: &[u8]). The previous signature
    rebuilt a fresh EncodingKey from raw PEM/HMAC bytes on every call
    -- for RS*/ES* algorithms, a new, un-zeroized jsonwebtoken
    PemEncodedKey on every single token request, per RFC 7523's
    per-request assertion regeneration. The new signature lets the
    caller construct the EncodingKey once and reuse it across calls,
    reducing that construction from one-per-call to one-per-key. This is
    not "zero un-zeroized copies" -- the caller's initial
    from_rsa_pem/from_ec_pem still builds one PemEncodedKey at
    setup time, and jsonwebtoken 10.4.0 does not zeroize that
    intermediate type (confirmed from its own changelog and source;
    only EncodingKey/DecodingKey themselves are zeroized) -- but
    N-to-1 is the real win given per-request regeneration.
    The previous signature made algorithm and the key family agree by
    construction, since it chose the PEM-parsing branch from algorithm.
    The new signature allows an EncodingKey of one family to be passed
    with an unrelated Algorithm (e.g. an HMAC key with Algorithm::ES256)
    with no compile-time guard; verified directly against jsonwebtoken
    10.4.0 (not assumed) that such a mismatch is rejected at encode()
    time with Error(InvalidAlgorithm), never silently signed under the
    wrong declared alg, so this cannot become an algorithm-confusion
    vector -- it is now a runtime error instead of a type-system
    guarantee, and both facts are documented on the method.

  • Breaking: Credential, SvidResponse, and CACertificate no
    longer derive Debug; each has a manual, redacting impl instead
    (Credential's delegates to its pre-existing but previously-unused
    safe_display(); CACertificate's and SvidResponse::X509's
    redact their private_key field, and SvidResponse::Jwt's redacts
    its token field). A derived Debug printing secret material
    verbatim on an accidental {:?} is a disclosure bug in its own
    right, independent of any zeroization; none of the three derives
    was load-bearing in-tree (checked individually before removing,
    including confirming the one apparent Credential serialization
    call site in passkey/mod.rs is actually an unrelated WebAuthn
    library type sharing the variable name credential). Debug output
    is not a stable public contract, so this part of the fix is itself
    non-breaking; it is grouped here because it is the first step of the
    breaking changes below.

  • Breaking: Credential::Certificate.private_key,
    CACertificate.private_key, and SvidResponse::X509.private_key
    change from Vec<u8> to Zeroizing<Vec<u8>>, so the key bytes are
    wiped from memory as soon as the value holding them drops, per
    CireSnave's standing rule that regenerable secret material should
    only be held in memory as long as the current work needs it.
    Zeroizing<T> derefs to &Vec<u8>/&[u8] so most call sites stayed
    source-compatible, but the field's public type changes on three
    pub types, which is a real break for any downstream code naming
    the concrete field type. Verified per-field rather than assumed safe:
    Credential::Certificate.private_key is never cloned or cached;
    CACertificate.private_key and SvidResponse::X509.private_key are
    each cached behind Arc<RwLock<HashMap<..>>> and cloned out of the
    lock at various call sites, and Zeroizing<Vec<u8>>'s Clone
    allocates an independent buffer per clone, so the cached original
    and any transient clone each zeroize independently on their own
    Drop -- for the SVID case this is a net improvement over the prior
    behavior, where every read cloned the private key and left it
    unwiped until ordinary deallocation. Required enabling the serde
    feature on the zeroize dependency, since SvidResponse still
    derived Serialize/Deserialize at this point in the sweep (see
    next entry) and Zeroizing<Vec<u8>> only implements those traits
    with that feature on.

  • Breaking: Credential, SvidResponse, and AuthRequest no
    longer derive Serialize/Deserialize. A derived Serialize on a
    type holding secret material is an active disclosure path reachable
    from one line of ordinary code (serde_json::to_string/to_value)
    -- the JSON-serialization analogue of the Debug disclosure path
    closed above. The derive was removed outright rather than replaced
    with a redacting custom Serialize, since a custom impl that
    silently drops the secret field would produce a structurally-valid-
    but-wrong value (e.g. an SVID with an empty private key, a
    Credential with an empty password) that either fails confusingly
    later or, worse, succeeds against a null credential; a loud compile
    break was judged preferable to that quiet wrong answer. Verified
    unused in-tree before removing each: no in-tree serde_json call
    actually serializes or deserializes a Credential (the one apparent
    hit is the same unrelated WebAuthn type noted above); SvidResponse
    is only ever held in an in-memory Arc<RwLock<HashMap<..>>> cache
    and re-issued on expiry, never serialized to storage or over the
    wire (SPIFFE's real Workload API wire protocol is gRPC/protobuf, not
    JSON); AuthRequest (which embeds Credential, so its own derive
    stopped compiling as a direct consequence) has zero in-tree callers
    of AuthRequest::new/with_metadata at all.

  • Breaking: roughly twenty secret-string fields across
    config, protocol, and consumer-credential types change from
    String/Option<String> to Zeroizing<String>/Option<Zeroizing<String>>,
    so the plaintext secret is wiped from memory as soon as the value
    holding it drops. Affected: MfaSetupResponse.secret; the email MFA
    provider configs' api_key/secret_access_key/password; the SMS
    MFA webhook's webhook_secret; GrafanaConfig.api_key; the app and
    deployment JwtConfig/SecurityConfig secret/JWT fields;
    SecurityConfig.secret_key/.previous_secret_key;
    RadiusConfig.shared_secret; StsConfig.jwt_signing_secret;
    SecureJwtConfig.jwt_secret; CaepConfig.signing_secret;
    SmsConfig.api_key/EmailConfig.password; OAuth 1.0a's consumer and
    token secrets (OAuthConsumer.secret, OAuthToken.secret,
    RequestTokenResponse/AccessTokenResponse.oauth_token_secret);
    EnhancedClientCredentials.client_secret_hash (a bcrypt hash, not
    the raw secret, zeroized anyway since an offline-crackable hash left
    resident longer than needed is the same class of risk at lower
    severity); the QuickStartAuth builder's JWT/OAuth2 secret fields;
    and MfaMethodData::Totp.secret_key. Each type was checked
    individually against the sweep's name list rather than trusted by
    name alone, since several names are reused for unrelated structs in
    this codebase (three distinct SecurityConfig types, two distinct
    JwtConfig types, two distinct OAuthProviderConfig types, two
    distinct WebhookConfig types) -- several instances turned out to
    hold no secret field at all and needed no change, including
    server::core::common_jwt::JwtConfig, which already uses
    jsonwebtoken's EncodingKey/DecodingKey (zeroized on Drop as
    of the create_client_assertion change above). Zeroizing<T>
    derefs to &T so most call sites stayed source-compatible, but it
    implements neither Display nor Into<String>, so format!()
    sites and builder methods taking impl Into<String> needed
    .as_str()/.to_string() (via Deref-based method lookup to the
    wrapped String's own methods, not a trait impl on Zeroizing
    itself) or a second .into() at construction. Verifying this class
    of change requires cargo check --all-features --all-targets, not
    just --lib or --all-targets alone: earlier passes in this sweep
    missed errors in test/example/bench code (--lib alone) and, once,
    two doc-comment construction sites invisible to both --lib and
    --all-targets (caught only by a targeted grep for the construction
    pattern plus a full doctest run).

  • LoginForm.password and CreateUserForm.password (private,
    non-pub admin-GUI form structs populated only by axum's Form
    extractor) now wrap their password field in Zeroizing<String> as
    well, for the same reason as the fields above. Non-breaking: both
    structs are private, so the field type was never part of this
    crate's public API, and every downstream use of .password already
    took a &str, which Zeroizing<String>'s Deref satisfies with no
    call-site changes.

  • Breaking: AdvancedTokenExchangeManager::introspect_jwt_token now
    actually verifies the token's signature (RS256, against the manager's
    configured key) and its exp/nbf claims, instead of calling
    jsonwebtoken::dangerous::insecure_decode and returning whatever
    claims an unverified token happened to contain. Any token that does
    not verify -- forged, signed by a different key, expired, not yet
    valid, or using a different algorithm (including alg-confusion
    attempts like HS256 signed with the public key as an HMAC secret) --
    is now rejected instead of silently accepted. Affects two callers:
    TokenExchangeService::validate_token (which previously reported
    is_valid: true for any syntactically-valid JWT regardless of who
    signed it) and apply_exchange_policies (which now fails the whole
    policy check, rather than silently skipping the trusted-issuer check,
    when the subject token fails introspection).

    • Known pre-existing limitations, not changed by this fix, worth
      knowing before relying on it for a new use:
      introspect_jwt_token
      does not check aud/iss/token purpose, so a token this manager
      minted for one purpose (e.g. an internal delegation-context token)
      will still introspect successfully if presented somewhere else --
      callers needing purpose-scoping must check those claims themselves.
      There is also no cross-issuer verification (no JWKS/per-issuer key
      lookup): a token genuinely signed by a different issuer's key
      always fails introspection with a signature error, never a trust
      decision, regardless of how "trusted" its claimed iss looks.
      validate_token also does not consult token revocation
      (revoked_token:{jti} storage) and surfaces the raw underlying
      jsonwebtoken error text to callers -- both pre-existing, tracked
      separately, not introduced or fixed here.
  • Breaking: UserManager::new now takes a second parameter,
    password_hash_algorithm: PasswordHashAlgorithm, and UserManager's
    own record-keeping now actually honors it. Previously,
    SecurityConfig::password_hash_algorithm's only read anywhere in the
    crate was a Display impl: register_user and update_user_password
    wrote two records per user -- user:{id} (hardcoded bcrypt) and
    user:credentials:{username} (hardcoded Argon2id, independent of the
    first) -- and the second record, not the first, is what
    verify_login_credentials (the actual login path, called from
    AuthFramework::authenticate_password_builtin) reads. Both records
    now hash with the configured algorithm. An earlier version of this
    fix touched only user:{id} and left the real login path's record
    hardcoded to Argon2id regardless of configuration; caught and fixed
    before merge, with tests on the login path specifically (not just the
    secondary user:{id} record) so this can't regress silently again.
    PasswordHashAlgorithm::Scrypt is rejected at AuthConfig::validate()
    (called by both AuthFramework::new_validated and initialize()),
    not only on first use, so a Scrypt misconfiguration surfaces at
    startup rather than at the first registration attempt.

    Verification is unaffected by this change in either direction:
    verify_user_password and verify_login_credentials both detect
    whether the stored hash is bcrypt or Argon2id from the hash's own
    prefix, so existing users keep verifying correctly across an
    algorithm change, and a deployment that switches its configured
    algorithm does not need to rehash or re-migrate any existing user.

    Behavior change for existing Bcrypt-configured deployments:
    SecurityConfig::development() and the security::presets Development
    preset already set `password_hash_algorithm: PasswordHa