Repository navigation
AuthFramework v0.6.0-rc166
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: anAuthStorageover a PersistAnt key-value backend (featurepersistant-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 pastexpires_atis gone at once, whereSqliteStoragekeeps returning it untilcleanup_expired.swap_kvis emulated with a batch precondition (supports_atomic_swap()istrue). Needs a backend with atomic batches (PersistAnt's memory, SQLite, PostgreSQL, MySQL/MariaDB). New dependenciespersistant-kvandpersistant-sqlx0.11.7 are optional and only compiled with the feature.storage::persistant_migrate::migrate_sqlite_to_storagecopies aSqliteStoragedatabase into anyAuthStorage(needssqlite-storagetoo); the examplepersistant_storage_speedtimes both (about 7 ms vs 6 ms per write, fsync-bound;get_token_by_access_token672 us vs 326 us;list_user_tokens2.1 ms vs 0.35 ms on the box it was run on).SqliteStorageis 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 aValidatedToken, which only the framework can
construct;check_validated_permissiononAuthFramework,AuthorizationManagerandPermissionCheckertakes that proof,
so a hand-builtAuthTokencannot reach it.AuthFramework::check_permissionbehaves as before and now goes through the
same path.PermissionChecker::check_token_permissionandAuthorizationManager::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 theremovekeys is live andkeyis absent: it then deletes everyremovekey and
storesvalueunderkey(returnstrue); otherwise it changes nothing and returnsfalse. 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 andsupports_atomic_swap()isfalse, 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 onAuthFrameworkand
tokens(), plusrotate_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 atomicstore_kv_if_absentclaim). 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 atomicstore_kv_if_absentrefuses 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 andvalidate_api_keyreturns it (UserInfo.roles). The allowed roles areadmin,userand
api_userby default and configurable withUserManager::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_keyis unchanged and createsapi_userkeys, and
keys stored before roles existed read asapi_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: anadminkey yields exactly the scope and roleadmin(what the crate's admin checks look for), a
userkey does not, and no other role name equals a scope name the crate checks (read,write); tests pin all
three. Who may mint anadminkey 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-GCMEncryptedStorage(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 byTotpManager::migrate_plain_secrets, which
touches exactlymfa_secret:*anduser:*:totp_secret(not the rest ofuser:), 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::newbehaves as before and logs ONE warning at construction when the
storage does not encrypt at rest;TotpManager::new_plaintextis the silent, explicit opt-in. New instorage::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 asneeds-discharge: xand 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-bigint0.7.5 -> 0.7.7 (RUSTSEC-2026-0387, timing variability in constant-time operations). Lockfile-only:cargo auditreports the vulnerability onmainand exits 0 after the update. RUSTSEC-2026-0374 (basic-toml, unmaintained) stays waived indeny.tomlwith its removal condition: it is a proc-macro dependency of the newest askama,web-guionly. - 0.6.0-rc126 -- WS-Federation sign-in tokens are verified before they are read.
WsFederationClient::process_sign_in_responsenow accepts a SAML 2.0 or 1.1 token only when its signature verifies (withbergshamra, viamethods::saml::dsig) under a key you configured:WsFederationConfig::trusted_signing_certificates(issuer and certificate) ortrusted_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::newfails unless at least one certificate or thumbprint is configured; JWT sign-in tokens are no longer accepted; without thesamlfeature no token is accepted. Also: a token with noNotOnOrAfterno longer overflows the expiry check, and SAML 1.1saml:-prefixedConditionsare 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, onbergshamra) under the certificate configured for the assertion's issuer, with every reference digest recomputed; only the signedAssertion(found by its uniqueID) is read afterwards, andInResponseTois taken from the signed assertion unless theResponseitself is signed. The ACS endpoint,SamlAuthMethodand 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/ssorefuses an IdP whosesaml_idp:{entity_id}has no usablesigning_cert;SamlAuthMethod::validate_configfails when no identity provider is configured, a certificate does not parse, orvalidate_xml_signatureisfalse. Removed:XmlCanonicalizerand the hand-written signature checks behindSamlSignatureValidator(which keepsvalidate_xml_signature(xml, cert_der), now backed bybergshamra);SamlSignatureValidator::extract_embedded_certificate. - 0.6.0-rc121 -- The
Debugaudit now checks fields of a type it does not know, and three secrets it found are redacted. The derive(Debug) audit skipped a hand-writtenDebugthat 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 foundMfaSetupResponse.secret(the TOTP secret) and the OAuth 1.0aRequestTokenResponse/AccessTokenResponseoauth_token_secret, whichDebugprinted in clear: all three now print<redacted>.BackChannelLogoutManager.rsa_signing_keyis redacted too, and three fields that hold only public information (VerifiedSdJwt.plaintext_claims, and the JWS algorithm names ofAdvancedJarmConfigandFapiConfig) 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 ProductionandNotify Teamjobs ofci-cd.ymlpushed images to Amazon ECR and updated an ECS service withAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, and posted to Slack withSLACK_WEBHOOK. None of the three secrets exists, nostagingorproductionenvironment is defined, there is nodevelopbranch, and the last two release-event runs skipped all of them. They are removed; container images are published toghcr.iobyrelease.ymlwith the workflow token. Every workflow already declares top-levelpermissions: contents: read. - 0.6.0-rc119 -- Dependencies:
tokio1.54.0 -> 1.54.1 inCargo.lock(RUSTSEC-2026-0384). Tokio 1.54.0, now yanked, has a race betweentokio::time::Interval::poll_tickand the time driver on the multi-threaded runtime that can free a timer entry still in use (use-after-free). The crate spawns loops thattick()anInterval(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 inCargo.tomlrises from1.47.1to1.54.1, so an application that depends on this crate cannot resolve the affected 1.54.0 either. - 0.6.0-rc118 --
Debug,Displayand the admin CLI no longer print webhook URLs, Redis passwords, session ids or header values.SlackConfigprinted its incoming-webhook URL (Debugis now hand-written and shows<redacted>, asWebhookConfigdoes).StorageConfig'sDisplay, and soAuthConfig's, printed the whole Redis URL, password included; it now printsredis(redis://host:port).RpInitiatedLogoutResponseprinted the ended session ids and now prints their fingerprints.AuthorizationContextprinted the values of its custom attributes, which are read fromX-Auth-*headers andctx_*query parameters; it now prints only their names, and so doesStepUpContextfor its own custom attributes.auth-framework-admin config showandconfig getmasked 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-secretsflag on both commands prints the real values (config showsays so on stderr). Tests were written first: fiveDebug/Displaychecks intests/secret_display.rsfailed with the marker in the output; the masking and flag-parsing tests are insrc/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),*_strnames,auth_req_id, lists of ids and the arguments of a nestedformat!,json!orserde_json::to_string. Twenty-four calls it now finds (CIBA, stepped-up authentication, federated orchestration, CAEP, OIDC extensions, the CIBA endpoint) logredact::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 asscheme://hostby the newredact::url_origin; the CAEP quarantine line logs a count, not the session ids. Areqwesterror 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 throughwithout_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.
PasskeyAuthMethodcould 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 storednull), and it read the
assertion'sclientDataJSON,authenticatorDataandsignatureonly as base64url text althoughpasskey-typeswrites 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. Newregister_credentialstores a credential an authenticator created elsewhere andregistered_passkey
reads one back. With authentication able to succeed, the pending challenge is now single use:complete_authenticationremoves 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
passkey0.5.0 assertion fails on the old code ("Missing clientDataJSON"), a live registration and assertion from thepasskeyclient 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
asredact::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
Debugimpls are audited field by field, and the fields it found are redacted. The audit checked derivedDebugonly
for plain-text fields and hand-written impls only for maps, so an impl that printedtoken,codeor a user code as it was passed.
Every.field(..)of a manual impl must now go throughredacted(..), 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: introspectiontoken,client_assertion,registration_access_token, the JARM
authorizationcode, the MFA code of a login request, deviceuser_codeandverification_uri_complete, the GNAPinteract_nonce,
OIDC sessionlogout_tokensandmetadata, tokencustomclaims, a threat-feed URL, a token-exchangejwt_verification_key, and the
password and configuration value typed on theadmincommand line.logout_tokens,interact_nonceandverification_uri_completemoved 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-Debugaudit looked at plain-text fields only, so a field such asDashMap<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, throughOption,Arcand locks), for derived and for hand-writtenDebugimpls: 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 thesidclaim
(filled from the session id in this crate), sessionid, MFAcode_hashand 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-
Debugaudit now sees one-line enum variants, and four more types stop printing credentials. The audit read one
field per line, so a variant writtenExternal { 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>,Bytesand[u8; N]; a further test fails if a macro defines a type, which the scan cannot see.
RedactingDebugonAuditStorage(the external-service key and the database connection string),DeviceAuthState(the access
token of an authorized request) andCaepRuleAction(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.rsgains 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:tscreported threeexactOptionalPropertyTypeserrors (AuthFrameworkError.statusCode,RateLimitError.retryAfter
and the request config inbase-client.ts),npm run buildfailed becausetslib(required by@rollup/plugin-typescript)
was not a dependency, andnpm testcould not start (jest-environment-jsdomwas not installed,jest.config.jsspelled
moduleNameMapperasmoduleNameMapping, andjest.setup.tsimported 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'smainpointed atdist/index.js, which the build never produces; it is nowdist/index.cjs.js. A new
"JavaScript SDK" CI job runs install, typecheck, test, build, a check thatmain/module/typesexist 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 --
zeroize1.9.1 or later is now required, because earlier versions printZeroizingsecrets in{:?}.Zeroizing<T>
hides its value fromDebugonly from zeroize 1.9.1 (2026-10-06); 1.9.0 and earlier deriveDebugand 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 forwardsDebug: configuration secrets (JwtConfig/SecurityConfig/
SecureJwtConfigkeys,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 nowzeroize = "1.9.1". Two tests pin it: one formats aZeroizingholding a
marker and checks the marker is absent, the other parsesCargo.tomland fails if the requirement is below 1.9.1 (forcing
zeroize 1.8.2 in a scratch copy made the MFA redaction test fail withZeroizing("...")). - 0.6.0-rc106 --
{:?}no longer prints a WS-Security password, OAuth authorization codes or refresh-token ids. RedactingDebug
onUsernamePassword(value),EnhancedAuthorizationCode(code),RefreshToken(token_id) andUserCredentials
(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,codeandtoken_idare not secret-sounding names).
Serializeis unchanged. The MFA enrolment test now also pins that theZeroizing<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 theredisdependency withoutredis-storage) builds again on
its own.storage::rediscallskey_names::without_shadowed_legacy, which was gated onredis-storageinstead of on the
redisdependency that compiles the module, so--no-default-features --features distributed-rate-limitingfailed with
E0425. The CI feature matrix always pairs features with the storage backends, so nothing built that combination. A new
"Feature Isolation" CI job runscargo check --lib --no-default-features --features <f>for each explicitly declared
feature, listed fromcargo metadataso 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-redirects1.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,tslibandjest-environment-jsdomnot 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 plainString/Vec<String>fields that the earlier
redaction audit's name match andString-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) andEnvironmentConfig.secrets. The audit now matches
Vec<String>fields and the namesbackup_codes,recovery_codes,seed,mnemonic,qr_code,otpauthand
*_codes; the device-flow QR code (public verification URI and user code, shown on screen) is allowlisted with a reason.
Serializeis 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 derivedDebugover plainStringfields, so a
tracingcall 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, providerOAuthTokenResponse, IndieAuth and UMA
responses and the API and AxumLoginResponse/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; andAuthTokenBuilder. Each prints<redacted>(an unset optional
token still printsNone);Serializeis unchanged. The audit that fails the build for a derivedDebugover 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 derivedDebug
over a plainString/Vec<u8>secret field, so atracingcall,dbg!or panic message that formatted one printed the
secret:AuthConfig(secret),ClientConfigand its builder, the OAuthTokenRequest(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-writtenDebugthat prints<redacted>for the secret (an unset
optional secret still printsNone). Fields held asZeroizing<String>were already redacted.Serializeis 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
Debugwith a plain secret-named field (its own fixtures show it flags a derived type, passes a hand-written one and
ignoresZeroizingand 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 deriveDebug. - 0.6.0-rc95 -- Storage settings that cannot be honoured are now refused instead of silently changed.
AuthFramework::quick_start()
withwith_postgres,with_postgres_from_env,with_redisorwith_redis_from_envused to log a warning and run on
in-memory storage when the matchingpostgres-storage/redis-storagefeature 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 --
PostgresStoragestores and reads back a token'suser_profile,permissionsandroles(#93).
AuthTokenderivesFromRow, so every field needs a column, butauth_tokenshad none for these three: any
token read failed withno 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) andstore_token/update_tokenwrite them. The test
pg_token_round_trips_profile_roles_and_permissionsfailed 3 of 3 before and passes after, also on a table
created with the old schema. All ninestorage_postgrestests 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 itsCREATE 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 ownpg_typecatalog (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 testpg_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 withSamlAuthMethod::newand your ownTokenManagerwhen tokens must verify
elsewhere. - 0.6.0-rc81 -- The JWT introspection manager no longer signs with a fixed key.
JwtIntrospectionManager::newsigned
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 byverify_jwt_response.newnow generates a random key per
instance (responses verify only through that manager). NewJwtIntrospectionManager::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::newfell 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 byvalidate_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::newreturnsResult<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: setJWT_SECRETor
ConfigBuilder::with_jwt_secretfor a stable key.
Changed
- 0.6.0-rc166 --
tests/critical_security_detection.rsreads the files that hold the OAuth2 flow, and a missing file fails it (#18). Its seven tests searchedsrc/server/oauth/oauth2.rs, a 159-line wrapper, forTODO: Validate client secret,user123and a hardcoded scope list, andtest_comprehensive_todo_auditskipped files that did not exist; a moved file read as no problem found. They now searchoauth2.rs,oauth2_server.rs,api/oauth2.rsandapi/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 mentionsclient_secret, and say only what the search shows.test_security_audit_effectivenessread only the top level ofsrc/and ignored a failed read; it walks the tree, fails on an unreadable directory and requires over 100 files.test_detectors_can_failplants each pattern and a missing subject. Test-only.code_quality_audit.rsalready 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/introspectand theauthorization_code/refresh_tokenexchange at/oauth/tokennow 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 429rate_limit_exceededat introspection,rate_limit_exceededfrom 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/revokeis not covered here. - 0.6.0-rc164 --
/oauth/revokeacts only on tokens this server issued, and only for the client they were issued to (RFC 7009). It used to store a 30-dayoauth2_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 aclient_id, or a stored refresh token) is revoked only by that client: a confidential client authenticates with HTTP Basic orclient_id/client_secretin the body, a public client names itself withclient_id; anything else isinvalid_clientand revokes nothing. A token with no client (a first-party session token) is still revoked by whoever holds it. Breaking:api::oauth2::revoketakes the request headers as a second argument, andRevokeRequestgains optionalclient_idandclient_secretfields; 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/introspectrecords every request as aTokenIntrospectedevent (who asked: the client id; the result:active,inactive_revoked_or_unusable,inactive_invalidorclient_authentication_failed; and the subject andjtiwhen the token could be read) and/oauth/revokerecords aTokenRevokedevent (the token's fingerprint, and subject andjtifor 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 withAuditStorage::Tracing, which is write-only). Breaking:AuditEventTypehas a new variant,TokenIntrospected, so an exhaustivematchover it must handle it./oauth/revokedoes not authenticate its caller, so its events name the actor asunauthenticated. The OAuth2client_credentialsgrant is not served by the HTTP token endpoint, so there is nothing to record there. - 0.6.0-rc162 -- PostgreSQL
list_user_sessionsworks on a database made bymigrate()(#147). It selectedlast_activity,ip_addressanduser_agent, columns thesessionstable (asmigrate()creates it) does not have, so every call failed, and it rebuilt the session'sdatafrom the whole JSON object. It now reads the onedatacolumn, the wholeSessionDatathatstore_sessionwrote andget_sessionreads. The othersessionstable 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_tokenandget_sessionon the SQLite, PostgreSQL, PersistAnt and in-memory (MemoryStorage,DashMapMemoryStorage) backends now returnNonefor a token or session whoseexpires_athas passed, as theAuthStoragedocs ask; before, only the in-memory sessions honoured it and the SQL backends pruned only incleanup_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_tokensandlist_user_sessionsare 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/introspectcompared 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 Basicclient_id:or a POST body with an emptyclient_secretauthenticated 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 getsinvalid_client(HTTP 401) there. Clients with a secret are unaffected;POST /oauth/registeralways 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/registernow storesclient_secret_hash(Argon2id) inoauth2_client:{id}and no plain secret; the secret is still returned once in the registration response. A client record that still holds a plainclient_secretkeeps 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 newapi::client_secretmodule. 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-opsfeature. - 0.6.0-rc154 --
tools/advisory-ranges: regenerates the RustSecpatched/unaffectedlists so0.6.0-rcNorders correctly. RustSec compares versions withsemver, which ordersrcNas text (rc101 < rc126 < rc41 < rc98); the tool turns the ranges we mean into exact=0.6.0-rcNentries 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
rsacrate. - 0.6.0-rc152 -- Security: GHSA-q4c2-8php-g7c8. Third-party caveats in
protocols::macaroonswere 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.rsused 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_transactionchecks 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 withct_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'sct_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 toee9c3950(0.9.24). - 0.6.0-rc144 -- Spec-to-roadmap audit.
docs/ROADMAP.mdgains rows A1-A11 for work the docs promise and the roadmap did not carry; the stale "release 0.5.0 from rc18" priority now names0.6.0.SECURITY.mdno 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.mdSQLite 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 inee6dd8d2, made by theopenssl genrsacommand in the fixtures README; no source, test, workflow or doc references it), is deleted.examples/debug_server.rsreads its signing secret fromAUTH_FRAMEWORK_DEBUG_SECRETand fails if it is unset, instead of carrying a literal. Thedocs/ROADMAP.mdSEC-002 row is updated. History is not rewritten. - 0.6.0-rc142 --
auth-framework.pngnow has a provenance record.PROVENANCE.mdstates 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 fmtclean again. The rc138 shared-signals fix was not rustfmt-formatted, so theCheck formattingstep failed onmain. Whitespace only insrc/server/oidc/oidc_extensions.rsandtests/shared_signals_verification.rs; no behaviour change. - 0.6.0-rc138 -- Shared Signals event verification fails closed.
SharedSignalsManager::receive_eventnow accepts an event only when it carries a signed token andSHARED_SIGNALS_VERIFICATION_KEYis 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
portfoliojob. Member crates and SDKs join the version:auth-storage-sqlite,sdks/pythonandsdks/javascriptnow carry the root version, held there bytests/member_versions_match_cargo.rs. No library change. - 0.6.0-rc131 -- Open issues are on the roadmap, and
oauth2_test_serverneedsapi-server.docs/ROADMAP.mdgains a "Tracked Issues" table (size, priority, state measured at main) for the open GitHub issues; theoauth2_test_serverbinary, thedebug_serverexample and five tests that importauth_framework::apinow declarerequired-features = ["api-server"], socargo check --no-default-features --all-targetsbuilds (#282). - 0.6.0-rc130 -- CI: advisory CodeRipper portfolio check (
portfoliojob, pinned reusable workflow). Non-blocking; not a required check. No library change. - 0.6.0-rc129 --
serde-saphyr2.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-escape0.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-xml0.42.0 (was 0.41). The SAML parsing code moves to its&strAPI (names, attribute values and text are&strnow). Behaviour is unchanged; known-answer tests cover carriage returns (character reference and raw line ends) in signed assertions. - 0.6.0-rc122 --
pasetors0.8.1 (was 0.7.7). No API change forPasetoLocalManager; a known-answer test confirms a v4.local token minted by 0.7.7 still decrypts. - 0.6.0-rc115 -- Dependencies:
passkeyandpasskey-client0.5 -> 0.6. The new release asks the caller for a crypto backend (Authenticator::new(.., RustCryptoBackend))
and no longer exportspasskey::types::rand; the passkey method now passesRustCryptoBackendand draws its 32-byte challenge fromring's system
generator. The lock also moves the RustCrypto elliptic-curve stack thatpasskeyuses (p2560.14,ecdsa0.17,elliptic-curve0.14,crypto-bigint
0.7,sec1,base16ct1.0) and addsed25519-dalek3 andml-dsa. No public item of this crate changes and no stored or wire format moves: a credential
and an assertion recorded withpasskey0.5.0 (tests/fixtures/passkey_wire_format.json) are checked against the same shape and verified withringalone,
and the test passes on 0.5.0 and on 0.6.0. Known issue, not caused by this change:register_passkeyreads the public key from a JSON path
that the serialized credential does not have (authenticatorDatais bytes), so the stored key isnullon both releases; tracked separately. - 0.6.0-rc110 -- CI: a weekly
cargo audit+cargo denyrun on every maintained branch. A new RustSec advisory reaches a release branch's
lockfile the day it is published, but lockfile refreshes land onmainalone, sorelease/0.4.xandrelease/0.5.xwent red
(RUSTSEC-2026-0381/-0382/-0383) only when an unrelated PR ran CI there. The new workflowsecurity-audit-branches.ymllistsmain
andrelease/*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 lacksmain, so the audit cannot pass vacuously. No code change. - 0.6.0-rc109 -- The derived-
Debugaudit is now a reviewed whitelist instead of a list of secret-sounding names, and about a hundred more types
redact theirDebug. The earlier audit failed the build only for fields whose names looked secret, so it missedvalue,codeand
token_id.tests/debug_field_review.txtnow lists every plain-text field name of everyDebug-deriving struct and enum insrc/
(about 680 types, 800 names) as public, secret-capable or ambiguous; a derivedDebugover 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 900Debug-deriving types and fails on seeded copies of the two leaks the old audit missed.
Review of the secret-capable names redactedDebugon 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;Serializeis 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 (
sha20.10 -> 0.11,sha10.10 ->
0.11,md-50.10 -> 0.11,hmac0.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
KeyInitimport where an HMAC is built from a key andhex::encodein place of{:x}on a digest, which 0.11 no longer
formats. - 0.6.0-rc103 -- BREAKING (dependency in the public API):
jsonwebtoken10.4 -> 11.1. Public items that take or return
jsonwebtokentypes (jsonwebtoken::Algorithm,DecodingKey,EncodingKeyinsecurity::secure_jwtand the JWT helpers)
now use 11.1's, so a downstream crate that names them must move tojsonwebtoken11 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 theaws_lc_rsbackend is kept, sorsais still absent from the dependency graph.
jsonwebtoken::Algorithmis 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 itsDebugname. 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 themtokio1.54.0,actix-web4.17.0 (withactix-http3.19 andactix-server2.10),
role-system1.3.0,rustls-webpki0.103.16,crc32fast1.5.3 andunicode-normalization0.1.26. No manifest change and no
source change. - 0.6.0-rc100 -- CI:
cargo denyignores RUSTSEC-2026-0374 (basic-tomlunmaintained). The advisory (published 2026-09-26, no
safe upgrade) turned the "Security Audit (deny)" job red.basic-tomlis a build-time proc-macro dependency ofaskama_derive,
and askama 0.16.1 -- the newest release -- still depends on it; only theweb-guifeature enables askama, and nothing is linked
into a built artifact.deny.tomlnow 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
smtpfeature.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; enablesmtpto 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 withsmtp. - 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_keyreplaces the old key's rows by the
new key's row in a singleswap_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.mdlisted 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 install0.6.0-rc50, nor to run the0.6.0-rc41image: 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, andSECURITY.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 onauth-framework0.1.0 (yanked) with featuresactix-web,warpandrocket, none of which is a feature of this
crate: it now namesactix-integrationandwarp-integration, takes the newest release of your line, and says plainly that
there is no Rocket integration.docs/guides/administrator-setup.mdnamed the imageauthframework/auth-framework:0.4.0
(a namespace this project does not publish to, at a yanked version): it now usesghcr.io/ciresnave/auth-framework:<tag>.
The README's development example no longer asks for atestingfeature 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: thecommon_jwt::utilsmodule (functions that
read JWT claims without verifying the signature, used only by their own tests),KerberosManager::generate_response_token(private),
the never-readcreated_atof the CIBA pending request andcode/code_challenge_methodof 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_legacyis now
compiled only withredis-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_redis6,kv_key_names_hold_no_credentials9,
storage_list_kv_keys1) are no longer#[ignore]d. CI's--ignoredstep ran onlystorage_redis, so the
key-name migration tests (a security property) and thelist_kv_keystest 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. TheRun ignored redis storage integration tests
step is removed (it would run zero tests). - 0.6.0-rc72 --
DistributedRateLimiter::with_clock, a testing seam likeRateLimiter::with_clock: the
in-memory windows and penalties read the supplied clock instead of the system one (newis 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_validityno longer depends on the
wall-clock second: it used a 1 s timeout and a real sleep, and failed whenever the second ticked over
duringcreate_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:7service
containers inci-cd.ymlare pinned by image digest, and everycargo build|test|check|clippy|bench
inci-cd.ymlandrelease.yml(pluscargo installofcargo-audit/crossandcargo llvm-cov)
now passes--locked, so CI builds the dependency setCargo.lockdescribes. Thecargo 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.gzfeed is now size-limited. New
ThreatIntelConfig::extraction_limits(ExtractionLimits:max_entries10 000,max_entry_bytes
512 MiB,max_total_bytes1 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 buildsThreatIntelConfigwith a
struct literal: addextraction_limits: Default::default(). - 0.6.0-rc68 -- Comparison helpers hardened:
SecureComparison::secure_string_compare,
constant_time_eq,constant_time_eq_bytesandverify_tokennow examine the full length of both
inputs. The wall-clocktest_timing_attack_resistanceis replaced by deterministic comparison tests. - 0.6.0-rc67 -- Removed the ZIP extraction path and the
zipdependency. Nothing reached it: the
threat-feed downloader extracts only a feed whose filename ends in.tar.gz, so a.zipfeed was
never selected, yet the dependency was non-optional and (in the 2.x line) unresolvable. A.zippath
given toextract_compressed_feedis now refused with a "not supported" error and nothing is written,
like.bz2and.xz. Fifteen crates leaveCargo.lockand none arrive (including the C-backed
bzip2-sys,lzma-sys,xz2andzstd-sys). If ZIP feeds are wanted later, support re-enters with
size limits and tests. - 0.6.0-rc66 --
zip2 -> 9.0.4: a fresh resolve of the published rc releases failed. Everyzip2.x
release is yanked on crates.io, andzip = "2"was a non-optional dependency, so a project without a
lockfile entry could not add this crate. Thezipproject 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_zipnever 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 makesenclosed_namereject a
separator inside one verbatim path component, which matters on Windows. Code change:zip9 returns an
entry's name as aResult(names need not be UTF-8), and it strips the root from an absolute name
instead of refusing it, soextract_zipnow refuses rooted names explicitly (/x, a backslash root,
C:), as it did underzip2. Newzip_extraction_testspin the Zip Slip paths (.., absolute,
rooted spellings, symlink entries, a backslash inside a component); removing theenclosed_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:zipis now built withdefault-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-backedbzip2-sys,lzma-sys,xz2andzstd-sysleave the
dependency tree with 9 other crates, and onlytyped-patharrives. Nodeny.tomlchange. 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.gzfeed, 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.
handlebars4.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_keysno 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::MockStorageand theauth-storage-sqlite
crate'sSqliteStorageimplement it (expired keys
are not listed; the Redis one uses SCAN with the prefix matched literally). Breaking for
out-of-treeAuthStorageimplementations 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-levelpermissions: contents: readinci-cd.ymland
release.yml(SUP-008 excessive-permissions; jobs that need more already declare it), and
records the waiver for the CI-onlypostgres:15/redis:7service-container images
(SUP-008 unpinned-images). No library or behaviour change. - 0.6.0-rc61 -- CI only.
ci-cd.ymlalso runs on pushes torelease/**branches, so the
release lines get the same checks. - 0.6.0-rc60 -- CI only. The GitHub Actions in
ci-cd.ymlandrelease.ymlare pinned to
commit SHAs (43 moved, 11 kept); no library change. - 0.6.0-rc59 -- Dependency only.
rustls0.23.45 -> 0.23.46 inCargo.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_keyreads 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, andrevoke/deleteremove 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::RedisStorageandstorage::core::RedisStoragelist sessions under both
naming schemes while old rows remain. - 0.6.0-rc55 -- Documentation only.
docs/STORAGE-AUDIT.mdis re-verified against
origin/main@3239b1c3and 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 refa254de6bas current. - 0.6.0-rc54 -- Documentation and a guard only. The health-response example in
docs/api/README.mdcarries the crate version and is now checked by
openapi_version_matches_cargotogether with theCurrent crate versionline 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.mdno 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 literal0.5.0-rc24on every release
since rc24; it now reports the crate version (env!("CARGO_PKG_VERSION")), so it cannot drift.
Consumers that read that field will see0.6.0-rc53. The checked-indocs/api/openapi.yaml,
openapi-modular.yaml,openapi-bundled.yamland the twoversionexamples in
docs/api/schemas/health.yamlare static and now carry 0.6.0-rc53; a release bump must update
them, and the new testopenapi_version_matches_cargofails the bump that forgets. Footers and
titles that named 0.5.0-rc24 inmigration-upgrade.md,production-guide.md,
security-configuration.mdandtroubleshooting.mdno longer name a version (and the
"THE premier" line is gone).docs/development/TESTING_RESULTS.mdis a recorded rc24
snapshot and is unchanged. - 0.6.0-rc52 -- Documentation only.
docs/guides/developer-integration.mdpins 0.6.0-rc51 (it
said 0.5.0-rc24) and no longer claims Axum integration and OIDC are default features (the
defaults areenhanced-rbacandpostgres-storage);docs/api/README.mdexample version;
docs/guides/administrator-setup.mdfooter 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 insrc/, 699 in
tests/) and its limits. The testreadme_claims_are_true_of_the_artifactnow 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 lacksnew_with_storage. New testreadme_claims_are_true_of_the_artifact.
Other docs underdocs/still carry 0.5.0-rc24 pins and are not changed here. - 0.6.0-rc49 -- CI only: the
Build and Test Docker Imagejob loads the test image into the
Docker daemon (load: true) and smoke-tests it (toolchain and database client tools) instead
of running a second fullcargo test --all-features --releaseinside a container without
Postgres or Redis. The job could never pass before (it randocker 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 scheduleaes-gcmderives from it is not. -
0.6.0-rc50 -- Breaking (custom storage backends): the default of
AuthStorage::store_kv_if_absentis now an
error instead of a non-atomicget_kvfollowed bystore_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 customAuthStoragemust
implementstore_kv_if_absentatomically (the in-tree memory, SQLite, PostgreSQL, Redis, unified
and encrypted backends andMockStoragealready do); until it does, the paths that need a claim
fail with an error naming the method. -
0.6.0-rc48 -- The
users:indexlist (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 Auditjobs red are gone from
Cargo.lockby dependency changes, with no ignore-list entry. YAML now usesserde-saphyr
(maintained, pure Rust) instead ofserde_yaml0.9 (deprecated, built on the unmaintained
unsafe-libyaml, RUSTSEC-2024-0449). Breaking: theYamlvariants ofAuthErrorand of
deploymentConfigErrornow wrapserde_saphyr::Errorinstead ofserde_yaml::Error, and YAML
output fromserde_saphyr::to_stringcan differ cosmetically fromserde_yaml's (quoting,
indentation). Parsing defaults differ too: duplicate mapping keys are an error,<<merge keys
are expanded, scalars follow YAML 1.2 rules (soyes/onand leading-zero numbers read
differently from YAML 1.1), and parsing is budgeted (nesting depth, alias/anchor and node counts).
Theconfig show --format yamlCLI path reports a serialisation failure asAuthError::Config
rather thanAuthError::Yaml.actix-webis now depended on without its default features (onlymacros), so
h20.3.x (RUSTSEC-2026-0258, no patched 0.3 release;actix-httprequiresh2 ^0.3.27) is no
longer in this crate's graph for any feature combination; an application that enables
actix-web'shttp2feature itself takesh20.3.x with it (seeSECURITY.md).cargo update
brought the lock file to the latest semver-compatible versions (h20.4.20 among them);
deny.tomlno longer lists RUSTSEC-2026-0258 and bans anyh2 < 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 declaretoken_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_tokenadditionally requires the value'sclient_idanduser_idto match the JWT
and the stored token;/oauth/introspectanswersactive: falsefor 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 newmfa_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 declaretoken_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-suppliedAuthTokenvalue to agree with the signed JWT it carries (user_idmust be
the JWT subject), andAuthFramework::refresh_token(&AuthToken)renews only a token that
validate_tokenaccepts (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
countermfa_failures:{user}is gone (a successful MFA login still clears the budget). -
0.6.0-rc46 --
OidcProvider::get_userinforefuses 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 atoken_useother 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. NewTokenManager::create_typed_jwt_tokenandJwtClaims::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/introspectreports a token as active only if it could be used: the
account must be live (deactivated or deleted accounts answeractive: false), and a refresh
JWT must be unspent and in an intact chain (it is reported withtoken_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
theclient_idclaim (TokenManager::create_client_jwt_token/
create_client_auth_token;AuthToken::client_idis 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 axumAuthenticatedUserextractor and
auth_middleware, the actix, warp and unified paths, andauthenticate("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/meaccept client-bound tokens. Migration: integrators who protect routes
meant for delegated (third-party) access should use the new opt-in: axumDelegatedUser
(instead ofAuthenticatedUser;user.0.token.client_idtells the route which client) or
AuthFramework::validate_delegated_jwt; scope is advisory unless the route checks it.
Access tokens issued before the upgrade carry noclient_idand keep working until they
expire (at most the token lifetime, 1 hour by default). Delegated routes must not
also sit behindauth_middleware(it refuses client-bound tokens). The standalone
OAuth2Servermodule now puts the client in the signed JWT of user-bound tokens, and
TokenManager::refresh_tokenkeeps a client-bound token client-bound. SDK users: a token
from/oauth/tokenis a delegated token; it works at/oauth/userinfoand/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 atomicstore_kv_if_absent, only for a code or token that
exists (so simultaneous redemptions succeed once and made-up codes store nothing);
/oauth/revokerejects tokens over 8 KiB;end_sessionalso records therevoked_token:{jti}
marker the bearer paths read;/auth/logoutwith 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/introspectreports a refresh
JWT as active; tokens minted by the OIDC id-token, SAML and passkey flows are not
distinguished from access tokens by atoken_useclaim; 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
TokenResponsegainsrefresh_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_VERIFICATIONaccepts the three values in any case and an unrecognised value
now meansrequired(with an error log) instead of silentlypreferred; the WebAuthn
handlers readWebAuthnConfig::from_env()per request, so origins and the
user-verification requirement are configured through the environment (theorigins()/
user_verification()builders only affect code that calls the helpers itself);
WebAuthnConfiggains the public fieldsrp_originsanduser_verification; the
conditional-permission context no longer setsconnection_type=vpnfrom a client-supplied
Viaheader; a genuine corporate client behind a proxy that addsVia: ... proxyis
assessedProxyrather thanCorporate;
TokenExchangeManagerFactory::create_basic_manager_with_revocationinstalls the revocation
list (the plain factory does not, andAdvancedTokenExchangeManagerhas no built-in
revocation or token-type check). -
0.6.0-rc46 -- Sessions:
rotate_on_privilege_escalation(theSessionConfigfield and
the builder method) is deprecated and documented as having no effect. It wastruein every
preset but nothing ever read it, so sessions were never rotated on privilege escalation; the
crate has no escalation event to hook. Userotate_periodicallyfor rotation. -
0.6.0-rc46 -- Sessions:
SessionManager::revoke_all_user_sessionsnow revokes every session
that is not already expired or revoked (it used to revoke onlyActiveones, 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 aViaheader. Network membership
(corporate_networks) and "direct" are decided from the peer address, the Tor user-agent
check always runs, and aViaheader can only raise the assessed risk (VPN/proxy). Before,
a request withoutViawas alwaysUnknown(+10 risk) and one with an arbitraryVia
could be assessedDirectorCorporate(-10).ContextBuilder::from_api_stateshares the
server's trusted-proxy list. Known, unchanged:login_risk_levelin the API login response
counts rawX-Forwarded-Forhops (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}(plushttp://localhostfor thelocalhostrp 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:rpIdHashmust be SHA-256 of the rp id and the user-presence flag must
be set, and the user-verification flag whenWEBAUTHN_USER_VERIFICATION=required.
The user-verification requirement now comes from server configuration (defaultpreferred),
not from the request body. Registration does not parse the attestation object, so its
authenticator data flags are not checked (only attestationnoneis 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
(UnifiedStorageandauth-storage-sqlitetoo; the test-onlyMockStoragekeeps the default). Refresh-token redemption uses it, so of any number of
simultaneous redemptions of one refresh token exactly one succeeds; customAuthStorage
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 (thepassword
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 namedjwtwas
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
eithertypspelling the crate mints (refreshandrefresh_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_addressand theip_addressentry 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 (rightmostX-Forwarded-Forentry that is not itself a trusted proxy). A raw
X-Forwarded-For/X-Real-IPis 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
withApiServer::with_trusted_proxies(the authorization middleware then uses the same list;
build a stand-aloneContextBuilderwithContextBuilder::from_api_state), and do not put
the proxy's own address incorporate_networksor anIpWhitelist: an
unlisted proxy makes every client look like the proxy.ip_addressis 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; aConnectInfo<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_tokenis a
new field ofRefreshResponse; 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_jwtis the
shared implementation.validate_access_jwtalso refuses tokens of a deactivated account
(so the axum, actix, warp and unified paths do, not onlyvalidate_api_token);
AuthFramework::validate_token(and the modular type's) refuses tokens on the revocation
list and stored-revoked tokens; OIDCget_userinforeads the revocation list;
TokenExchangeManager::with_revocation_storemakes 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 byvalidate_access_jwtandredeem_refresh_jwt, as
validate_api_tokenalready did; JWT credentials passed toauthenticate(federated
OIDC/OAuth2 subjects) may still have no local record, but a deactivated local record is
refused there too.POST /auth/refreshanswersINVALID_TOKENfor a
deactivated account (it used to answerACCOUNT_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_jwtis 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 axumauth_middleware/AuthenticatedUser/ refresh handler,
the actix middleware and permission checks, the warp filter, the unified validator,
OAuthuserinfo, and JWT credentials passed toauthenticate. Tokens revoked by logout or
revocation, and refresh JWTs, are therefore refused on those paths (previously only
validate_api_tokenrefused revoked tokens).AuthFramework::revoke_tokennow also writes
the revocation marker. Introspection answersactive: falsewhen the revocation list
cannot be read. Rejections areAuthError::Token(Invalid)(HTTP 401 in the axum
integration) instead ofAuthError::Unauthorized. Cost: one or two extra storage reads
per authenticated request (the revocation list, and the account record on the integration
paths); customAuthStorageimplementations must returnOk(None)for a missing key.
AuthFramework::validate_tokenand the modular type's now also read the revocation list
(see the later entries); they still validate the caller'sAuthTokenvalue rather than the
JWT's own claims, andAuthFramework::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/loginare
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_mfanow signs in the user of the STORED challenge rather than the
user_idof the challenge value the caller passes. A user keeps at most 3 pending
challenges (a new one retires the oldest)./auth/loginanswersACCOUNT_LOCKEDinstead
of issuing an MFA challenge while the lockout is active. The one-use rule also applies to
TotpManager::verify_codecallers: a code already accepted for a time step is refused the
second time.AuthFramework(not the modular type) gainsmfa_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-IPare believed only when the
peer is listed in the newApiServer::with_trusted_proxies(default: none), using the
rightmost entry that is not itself a trusted proxy.ApiServer::startserves with
connect-info. Action required if you servebuild_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 withwith_trusted_proxies, otherwise every client appears as the proxy.
ApiStategains atrusted_proxiesfield. Every trusted proxy must APPEND to
X-Forwarded-For(not pass the client's value through);X-Real-IPis 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/authorizerefuses 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/refreshaccepts 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
newEmailVerificationSendertrait (install one with
ApiState::with_email_verification_sender) and is no longer part of any HTTP response.
POST /auth/resend-verificationnow returns one identical response for every address
(unknown, pending, already verified, delivery failed). Breaking: the
verification_tokenfield is removed fromVerificationSentResponse;ApiStategains
anemail_verification_senderfield (useApiState::new+ the builder). Without a
sender installed, no message can be delivered and email verification cannot complete
(ApiServer::with_email_verification_senderinstalls 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 clearsemail_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
originis 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_idand the tokensubare the account id, not
the username; POST /webauthn/registration/initand/completerequire
Authorization: Bearer ...;- the default
WEBAUTHN_ATTESTATIONisnone, and any other value makes
registration refuse (the server cannot verify attestation statements); - tokens whose
subhas no user record are rejected by every bearer-protected
endpoint.
- passkeys are stored per account id instead of per username, so all existing
-
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 isinvalid_client. The client must be registered (an unregistered
client_idisinvalid_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
isinvalid_grant, and neither that refusal nor a failed client authentication
consumes the code (previously the code was deleted before theclient_idcomparison,
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_idis now
redeemable ONLY by that client (RFC 6749 section 6). A confidential client (its
registered record holds aclient_secret) must authenticate withclient_id+
client_secret, and the authenticated client must be the one the token was
issued to: missing or wrong credentials areinvalid_client; a different,
validly authenticated client isinvalid_grant. A public client must send a
matchingclient_id; missing or different isinvalid_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-emptyclient_secret(the same test
/oauth/authorizeuses); 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
throughnew_with_storage/replace_storage/ the builder's
custom_storagebypasses the factory's automatic wrapping, so with
storage_encryption.enabled(the default) and a storage that is not an
EncryptedStorageKV values were silently NOT encrypted at rest;
initialize()now logs a warning, and fails when the new
storage_encryption.require_wrapped_storageistrue(new
AuthStorage::encrypts_kv_at_rest, defaultfalse,truefor
EncryptedStorage). (2) New opt-in
AUTH_STORAGE_ENCRYPTION_STRICT_KEY_FILE_PERMISSIONS=1turns the
group/other-readable keys-file warning into a startup error (Unix).
(3) The singleAUTH_STORAGE_ENCRYPTION_KEYenv var cannot rotate; this is
now documented and the storage factory warns when it protects data that is
already encrypted. Breaking:StorageEncryptionConfiggains
require_wrapped_storage(struct literals need..Default::default()). -
0.6.0-rc33 -- storage-encryption migration keeps KV TTLs.
security encrypt-kv/
migrate_kv_to_encryptedused 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 (newAuthStorage::tracks_kv_ttl; all built-in
backends do). A real run on a backend that cannot report TTLs is refused
unlessMigrationOptions::accept_ttl_loss/--accept-ttl-lossis given,
and a backup on such a backend logs a warning. Breaking:
MigrationOptionsgains theaccept_ttl_lossfield. -
0.6.0-rc24 -- storage encryption envelope format v2 (storage-format-affecting):
the AAD now binds the envelope's own
v,algorithmandkey_idas well as the record's storage key, so
relabelling a key id or rewritingvto 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 bysecurity 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-emptykey_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, andsafe_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 ofallow_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 = falsestill write plaintext snapshots.
Restore also preserves KV TTLs now (it used to make every entry
permanent) via a newAuthStorage::get_kv_ttlmethod (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 followsconfig.storagelike the storage factory: storage
supplied throughnew_with_storage/replace_storage/
custom_storagewhile the config still saysMemorygets 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 thersa-verifyfeature, and JARM's RSA-OAEP JWE via
rsa-private-key-ops) no longer depends on thersacrate
(RUSTSEC-2023-0071, an unpatched RSA timing sidechannel) -- backed by
aws-lc-rsinstead. 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-storagedepended
onrsavia a separate path (sqlx-mysql'scaching_sha2_password
auth plugin) -- see themysql-storageremoval entry below, which
closes this. -
SAML (
samlfeature) no longer depends on thersacrate either:
bergshamrais bumped 0.3.1 -> 0.9.2 and switched from its default
rustcryptobackend to itsaws-lcbackend (via a new direct
krypteringdependency), so IdP signing-key verification now goes
throughaws-lc-rsfor RSA, P-256, and P-384 keys instead of the
rsa/p256/p384RustCrypto crates.KeyDataconstruction 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-lcbackend drops several weaker signature algorithms that
bergshamra's defaultrustcryptobackend (and its optional
legacy-algorithmsfeature, not enabled here) supported: RSA-SHA224,
RSA-PSS with SHA1/SHA224/SHA3-, ECDSA with SHA1/SHA224/SHA3-, and
(vialegacy-algorithmsspecifically) DSA, MD5, and RIPEMD160.
RSA-SHA1 -- the one legacy combination real-world SAML IdPs still
sometimes use -- is unaffected and continues to work. - Breaking:
samlno longer builds on Windows.kryptering's
aws-lcfeature only compiles on Linux and non-FIPS macOS
(x86_64/aarch64); it hard-errors viacompile_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 tokryptering, bypassing
it for just this one code path, or a platform-conditional dependency
split).
- the same 2048-bit RSA key-size floor from the jsonwebtoken/JARM
-
Breaking:
mysql-storagehas been removed entirely. Per
CireSnave: "Drop MySQL. I'm not a huge fan of Oracle products
anyway." This was the last remaining path to thersacrate
(RUSTSEC-2023-0071) viasqlx-mysql'scaching_sha2_password
auth-plugin dependency.MySqlStorage,StorageConfig::MySQL, the
mysqlconfig builder method, andMySqlMigrationManagerare all
gone; amysql://DATABASE_URLnow returns a clear config error
instead of silently requiring a feature that no longer exists.
Migration: usepostgres-storage,sqlite-storage, or
redis-storageinstead. If MySQL support is a hard requirement, pin
to the last version that had it:0.4.4(stable) or0.5.0-rc25
(release candidate) -- neither will receive further updates.
Separately found and fixed while verifyingrsais now absent from
every default-feature build: theopenid-connectfeature's
openidconnectcrate 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 assertsrsais absent from every shipped feature combination
exceptsamlandopenid-connect, which have their own tracked
exceptions (deny.tomldocuments both). -
Breaking: each storage-backend feature now enables only its own
sqlxsub-feature --postgres-storage->sqlx/postgres,
mysql-storage->sqlx/mysql,sqlite-storage->sqlx/sqlite.
Previously a singlesqlxdependency declaration listed all three
sub-features unconditionally, so enabling any one storage backend
(includingpostgres-storage, which is indefault) silently built
sqlxwith full MySQL support too, and therefore pulled in thersa
crate (RUSTSEC-2023-0071, see above) viasqlx-mysql's
caching_sha2_passwordauth-plugin dependency -- regardless of
whethermysql-storagewas ever requested. Combined with the RSA
backend switch above, default builds (andpostgres-storage-only,
sqlite-storage-only builds) no longer depend onrsaat all;
mysql-storageis now the only storage feature that still does,
isolated to that one feature rather than spread across every build
that touchessqlx. -
Breaking:
create_client_assertion(RFC 7523 private-key-JWT client
authentication) now takesencoding_key: &EncodingKeyinstead of raw
signing-key bytes (signing_key: &[u8]). The previous signature
rebuilt a freshEncodingKeyfrom raw PEM/HMAC bytes on every call
-- for RS*/ES* algorithms, a new, un-zeroizedjsonwebtoken
PemEncodedKeyon every single token request, per RFC 7523's
per-request assertion regeneration. The new signature lets the
caller construct theEncodingKeyonce 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_pemstill builds onePemEncodedKeyat
setup time, andjsonwebtoken10.4.0 does not zeroize that
intermediate type (confirmed from its own changelog and source;
onlyEncodingKey/DecodingKeythemselves are zeroized) -- but
N-to-1 is the real win given per-request regeneration.
The previous signature madealgorithmand the key family agree by
construction, since it chose the PEM-parsing branch fromalgorithm.
The new signature allows anEncodingKeyof one family to be passed
with an unrelatedAlgorithm(e.g. an HMAC key withAlgorithm::ES256)
with no compile-time guard; verified directly againstjsonwebtoken
10.4.0 (not assumed) that such a mismatch is rejected atencode()
time withError(InvalidAlgorithm), never silently signed under the
wrong declaredalg, 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, andCACertificateno
longer deriveDebug; each has a manual, redacting impl instead
(Credential's delegates to its pre-existing but previously-unused
safe_display();CACertificate's andSvidResponse::X509's
redact theirprivate_keyfield, andSvidResponse::Jwt's redacts
itstokenfield). A derivedDebugprinting 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 apparentCredentialserialization
call site inpasskey/mod.rsis actually an unrelated WebAuthn
library type sharing the variable namecredential).Debugoutput
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, andSvidResponse::X509.private_key
change fromVec<u8>toZeroizing<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
pubtypes, which is a real break for any downstream code naming
the concrete field type. Verified per-field rather than assumed safe:
Credential::Certificate.private_keyis never cloned or cached;
CACertificate.private_keyandSvidResponse::X509.private_keyare
each cached behindArc<RwLock<HashMap<..>>>and cloned out of the
lock at various call sites, andZeroizing<Vec<u8>>'sClone
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 theserde
feature on thezeroizedependency, sinceSvidResponsestill
derivedSerialize/Deserializeat this point in the sweep (see
next entry) andZeroizing<Vec<u8>>only implements those traits
with that feature on. -
Breaking:
Credential,SvidResponse, andAuthRequestno
longer deriveSerialize/Deserialize. A derivedSerializeon 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 theDebugdisclosure path
closed above. The derive was removed outright rather than replaced
with a redacting customSerialize, 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
Credentialwith 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-treeserde_jsoncall
actually serializes or deserializes aCredential(the one apparent
hit is the same unrelated WebAuthn type noted above);SvidResponse
is only ever held in an in-memoryArc<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 embedsCredential, so its own derive
stopped compiling as a direct consequence) has zero in-tree callers
ofAuthRequest::new/with_metadataat all. -
Breaking: roughly twenty secret-string fields across
config, protocol, and consumer-credential types change from
String/Option<String>toZeroizing<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'swebhook_secret;GrafanaConfig.api_key; the app and
deploymentJwtConfig/SecurityConfigsecret/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); theQuickStartAuthbuilder's JWT/OAuth2 secret fields;
andMfaMethodData::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 distinctSecurityConfigtypes, two distinct
JwtConfigtypes, two distinctOAuthProviderConfigtypes, two
distinctWebhookConfigtypes) -- 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'sEncodingKey/DecodingKey(zeroized onDropas
of thecreate_client_assertionchange above).Zeroizing<T>
derefs to&Tso most call sites stayed source-compatible, but it
implements neitherDisplaynorInto<String>, soformat!()
sites and builder methods takingimpl Into<String>needed
.as_str()/.to_string()(viaDeref-based method lookup to the
wrappedString's own methods, not a trait impl onZeroizing
itself) or a second.into()at construction. Verifying this class
of change requirescargo check --all-features --all-targets, not
just--libor--all-targetsalone: earlier passes in this sweep
missed errors in test/example/bench code (--libalone) and, once,
two doc-comment construction sites invisible to both--liband
--all-targets(caught only by a targeted grep for the construction
pattern plus a full doctest run). -
LoginForm.passwordandCreateUserForm.password(private,
non-pubadmin-GUI form structs populated only by axum'sForm
extractor) now wrap their password field inZeroizing<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.passwordalready
took a&str, whichZeroizing<String>'sDerefsatisfies with no
call-site changes. -
Breaking:
AdvancedTokenExchangeManager::introspect_jwt_tokennow
actually verifies the token's signature (RS256, against the manager's
configured key) and itsexp/nbfclaims, instead of calling
jsonwebtoken::dangerous::insecure_decodeand 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: truefor any syntactically-valid JWT regardless of who
signed it) andapply_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 checkaud/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 claimedisslooks.
validate_tokenalso does not consult token revocation
(revoked_token:{jti}storage) and surfaces the raw underlying
jsonwebtokenerror text to callers -- both pre-existing, tracked
separately, not introduced or fixed here.
- Known pre-existing limitations, not changed by this fix, worth
-
Breaking:
UserManager::newnow takes a second parameter,
password_hash_algorithm: PasswordHashAlgorithm, andUserManager's
own record-keeping now actually honors it. Previously,
SecurityConfig::password_hash_algorithm's only read anywhere in the
crate was aDisplayimpl:register_userandupdate_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 onlyuser:{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
secondaryuser:{id}record) so this can't regress silently again.
PasswordHashAlgorithm::Scryptis rejected atAuthConfig::validate()
(called by bothAuthFramework::new_validatedandinitialize()),
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_passwordandverify_login_credentialsboth 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 thesecurity::presetsDevelopment
preset already set `password_hash_algorithm: PasswordHa