Uh oh!
There was an error while loading. Please reload this page.
feat(auth): encrypt the OIDC SSO clientSecret at rest (#8009) - #8223
Conversation
`sys_sso_provider.oidc_config` stored the OIDC `clientSecret` byte-for-byte in cleartext — measured on the real write path in #8009 step 0. That secret authenticates the platform to the IdP, and the object is readable through the generic data API (`apiMethods: ['get','list']`). Per the 2026-08-12 maintainer ruling this is option 1: split ONLY `clientSecret` out of the blob, into a `Field.secret()`-backed column, leaving endpoints/scopes/ mapping readable for the admin UI. Not option 2 (whole blob encrypted) and not option 3 (redact on read, which leaves cleartext at rest). - `sso-client-secret.ts` — the seam between better-auth and its adapter. It does not encrypt anything itself: it writes cleartext into the `secret`-typed column exactly once and lets the engine's `ICryptoProvider` path wrap it, inheriting the engine's fail-closed posture. Same shape as `webhook-secret.ts` (#7799) and the same privileged accessor (#7823). - Both write doors covered: adapter `create` (/sso/register), `update` (/sso/update-provider) and `updateMany`. A create-only seam would write cleartext back on the first config edit. - Decrypt-on-read is mandatory, not optional: `/sso/callback` reads the blob expecting plaintext, so `findOne`/`findMany` re-inject it via `engine.resolveSecretField()`. Encrypt-only would break every federated login. - Existing rows are migrated forward at start, with the #8022 crypto-provider race handled the way plugin-webhooks handles it. An un-migrated row keeps working and is reported, never silently left looking protected. The column is declared from plugin-auth's manifest via `objectExtensions` because the object's definition file lives in `packages/platform-objects` (`domain:metadata`'s package) while this object is registered and owned by plugin-auth. Consolidating it onto the object file is a move, not a behaviour change — see the PR body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8009
The OIDC
clientSecretstopped being stored in cleartext. It now lives on the engine's encrypted credential channel, and/sso/callbackstill gets the plaintext it needs, so federated login is unchanged.What was measured first, before any design
The dispatch made one thing gating: does
Field.secret()actually encrypt, and where does the key come from? Inventing a key-management mechanism would have been a maintainer-floor decision, so this was measured before a line was written.A complete mechanism already exists, and is production-wired. Nothing new was invented:
ICryptoProvidercontract (encrypt / decrypt /rotateKey/digest)packages/spec/src/contracts/crypto-provider.tsLocalCryptoProvider— AES-256-GCM keyed offOS_SECRET_KEY, fails loud in production rather than minting an ephemeral keypackages/services/service-settings/packages/cli/src/commands/serve.ts(dataEngine.setCryptoProvider)objectqlencryptSecretFieldskms_key_id/version/rotated_atfor rotationsys_secretengine.resolveSecretField()— added by #7799 for exactly this, and the #7823 shape the ruling namedMeasured round trip on
sys_sso_provideritself: at rest the column holdssecret:sec_1, the generic read path returns the mask, andresolveSecretFieldreturns the plaintext. KMS/Vault providers plug into the same interface, so managed custody needs no change here.The shape
Ruled option 1 — split only
clientSecret, leave the rest of the blob readable so the admin UI can still render endpoints, scopes and mapping.Three things this deliberately does not do:
secret-typed column exactly once and lets the engine wrap it — which inherits the engine's fail-closed posture for free. No CryptoProvider means the registration is refused with a 500, never stored in cleartext in a column that advertises itself as encrypted. Same reasoning aswebhook-secret.ts([security] The webhook signing secret is stored in cleartext insys_webhook.definition_json#7799).sys_accountstores live third-party OAuth access/refresh/id tokens as plain columns, and the object is API-readable #7987.Both write doors are covered. A create-only seam would encrypt at registration and then write cleartext back on the first config edit — the worst shape, because the column would then only look protected.
/sso/update-providerwas verified live: it returns 200 and the rotated secret is what reads back.Migration disposition for existing rows
Required by the ruling, so stated plainly.
Rows written before this change keep their cleartext
clientSecretinsideoidc_config. Two things happen:scheduleLegacySsoSecretMigrationrewrites every such row through the engine, which lifts the secret into the encrypted column and drops it from the blob. Bounded (SSO providers are env-global admin config, a handful of rows), idempotent, and it re-runs when the host wires the CryptoProvider — because plugins run insidekernel:readywhileserve.tsinjects the provider only afterruntime.start()returns, the same raceplugin-webhookshandles for Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, nosys_http_deliveryrow, while it still readsactive:true#8022.What happens to a row that is not migrated: it keeps working and stays cleartext, and it is reported with a warning naming the row and the reason. It is never rewritten into a half-migrated state, and it is never silently left cleartext behind a column that claims to be encrypted. The only way to reach that state is an environment with no CryptoProvider — where new registrations are refused outright anyway.
Verification
Predict-then-mutate ablation. The prediction was written down first, then the seam was broken in two places. Both halves were predicted plain red in different tests — a seam with only one observable direction would be indistinguishable from a test that asserts nothing.
expected 200 not to be 200, because with no secret field written the engine has nothing to refuseclientSecretabsent not wrong; at-rest stays greenexpected undefined to be '...', ① greenNeither produced "more diagnostics" or an inverted direction; there is no counting gate downstream of this seam and no canonical-first
??chain that could reroute the verdict.Same-source contamination. The expected plaintext is a literal chosen by the test and fed in through the public
/sso/registerendpoint; nothing in the implementation supplies it. The at-rest assertion greps the raw driver row, every column serialized, so moving the secret into some other cleartext column would still fail it — anoidc_config-only check would not. It also asserts the column holds asecret:ref and that thesys_secretciphertext is not the plaintext. Ablation B's failure message (expected undefined to be 'super-secret-...') is direct evidence the comparison is against the test's own constant, not against anything the seam produced.Nine cases: no plaintext in any column after register; correct plaintext recovered through better-auth's own adapter the way the callback reads it; the update door; fail-closed with no CryptoProvider; legacy row still logs in, migrates, and the sweep is idempotent; plus partial-update, wrong-object, malformed-blob and column-type unit cases.
One thing for review: where the column is declared
The field is declared from plugin-auth's manifest via
objectExtensions, not on the object file. Worth a deliberate look, because it is the one judgment call here.sys_sso_provider's definition file lives inpackages/platform-objects—domain:metadata's package — and this lane was told to stop rather than write into it.authIdentityObjects, commented "Identity objects owned by plugin-auth"), and the mechanism isdomain:identity's. The PM's own re-route reasoned that "the object file is the marking, not the mechanism".objectExtensionsis a first-classdefineStackkey, and was measured to behave identically to an inline declaration in the exact production ownership shape (same package owning and extending): DDL column created, encrypt-on-write fires, mask on read, privileged dereference works.The honest cost: the spec documents
objectExtensionsas being for "objects owned by other packages", and a reader ofsys-sso-provider.object.tswill not see this column there — which is a mild version of the declaration-does-not-match-reality problem this very card is about. Recommendation: consolidate the declaration onto the object file when the already-queueddomain:metadatacard opens it to fix the:99helpText (measured false on this card). That is a move of about five lines with no behaviour change; everything else here is declaration-site-independent.Not in scope
sys-sso-provider.object.ts:99's "stored encrypted by better-auth" helpText is measured false and still says so. It is being split into its owndomain:metadatacard; not touched here.oidcConfig.mapping.id, which@better-auth/sso@1.7.0-rc.2rejects as an unrecognized key #8193 — OIDC registration through theregister_sso_providerUI action currently 400s (oidcConfig.mapping.idagainst az.strictObject). Filed separately, awaiting triage. The tests drive/sso/registerdirectly to work around it, exactly as the step-0 harness did. Same adapter, same write door.apiMethodsnarrowing on the object — another platform-objects change, and moot for this column now that it returns the mask.Generated by Claude Code