Skip to content

test(plugin-auth): pin credential-at-rest posture for scim_token / client_secret - #8305

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8192-pin-credential-at-rest-posture
Aug 13, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-8192-pin-credential-at-rest-posture

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8192

Not a defect fix. #8011 measured that both credential columns already store a one-way SHA-256 digest; this PR is the tripwire that holds that posture in place. Before it, grep -rn 'storeSCIMToken|storeClientSecret' --include=*.test.ts packages/ returned zero hits.

The exposure being pinned

@better-auth/scim's own default is storeSCIMToken: "plain" — cleartext. A single option literal in auth-manager.ts is the only thing between a live IdP bearer and a cleartext value in a column that is readable over the generic data API (sys_scim_provider declares apiMethods: ['list']). The planned SCIM rc.2 migration (#3653) changes that exact call site's construction signature.

What makes the pin non-vacuous

  1. It drives AuthManager, not scim(). The load-bearing choice. A test that calls scim({ storeSCIMToken: 'hashed' }) itself pins its own literal and would stay green forever after the repo's literal was deleted — certifying exactly the regression it was written to catch. Every assertion runs against a plugin list built by AuthManager.buildPluginList().
  2. It reads the row at driver level, below the ObjectQL read mask and below the plugin's own accessor.
  3. It asserts the hash relationship, recomputed independently with node:crypto. "Differs from the plaintext" would be satisfied by base64 of the plaintext.
  4. The inner-token trap is an explicit negative. The SCIM bearer is base64url("BASE:providerId:organizationId") while the stored digest covers the inner BASE alone, so hashing the full bearer must NOT match. Without that case a future rewrite could "simplify" the positive into the naive shape that fails on correct code.
  5. A control arm measures upstream's default rather than citing it: scim() with no option stores the bearer in cleartext. That is what makes the literal demonstrably load-bearing, and it is the permanent form of this card's ablation.

Each positive case also asserts the credential still authenticates, so "hash it" cannot be satisfied by storing garbage.

Asymmetry found while building this

The two upstream helpers end differently on an unrecognised storage method: storeSCIMToken() falls through to return scimToken (cleartext, silently), while storeClientSecret() ends in throw new BetterAuthError. So client_secret cannot reach cleartext via a dropped literal at all — the only non-hashed value the plugin accepts is "encrypted", and only with disableJwtPlugin, which its own validation cross-checks. That is recorded in the test header; it is why the SCIM row is the sharp one.

Ablation — predicted first, then measured

mutation predicted measured
storeSCIMToken: 'hashed' becomes 'plain' 1 red (the at-rest case), stored = raw inner base token exactly that: 'mkDp7IWxEjL7ugQQn_kmpgup' vs the expected digest. 1 failed, 3 passed
storeClientSecret set to a pass-through custom hasher (a { hash } object returning its input unchanged) 1 red, stored = the 32-char plaintext exactly that: 'xRHyKIHKCyRKAuJlOFkcZJfMBZUsdYSx'. 1 failed, 3 passed

Note the second mutation's shape: 'plain' is not usable as an ablation for the OAuth column, because storeClientSecret() throws on it rather than storing cleartext.

The table includes the deliberate prediction that the AUTHENTICATES case stays green under the SCIM ablation — verifySCIMToken mirrors the configured storage method, so with 'plain' it compares plaintext to plaintext and still succeeds. That case is a liveness arm, not a posture detector, and the ablation is what establishes the difference.

No rebuild was needed for the ablations: the test imports ./auth-manager.js relatively, so vitest transforms the package's own source rather than a consumed dist/. Both mutations took effect immediately, which is itself the evidence.

Restores were done with git checkout BRANCH -- PATH from the commit; no git stash at any point.

Verification

  • pnpm --filter @objectstack/plugin-auth test — 47 files, 1093 tests, all pass (4 new).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean.
  • Type-check ledger: the new file initially drifted plugin-auth's TEST_DEBT +1 (registerObject requires packageId, which several older harnesses omit). Fixed the type rather than raising the ledger; re-measure is back at the recorded 111 and reports "surplus: none".
  • Gates re-derived with scripts/pm/dispatch-gates.mjs, run after a full pnpm build. All green except check:objectui-pin-fresh, which is a stale .objectui-sha in the repo state and untouched by this PR.

Generated by Claude Code

…ient_secret

Both columns already store a one-way SHA-256 digest, but nothing held them
there: no test referenced storeSCIMToken or storeClientSecret anywhere in the
repo, and @better-auth/scim defaults to storeSCIMToken: 'plain' (cleartext).
A single option literal in auth-manager.ts was the only thing keeping a live
IdP bearer out of an API-readable column, and the planned SCIM rc.2 migration
(#3653) changes that exact call site's signature.

The pin drives AuthManager's own plugin construction rather than calling
scim({ storeSCIMToken: 'hashed' }) itself -- a test that passed the literal
would certify the very regression it exists to catch. It reads the stored row
at driver level, recomputes the expected digest with node:crypto, and pins the
inner-base-token trap as an explicit negative case (hashing the full bearer
must NOT match). A control arm measures upstream's cleartext default so the
literal is demonstrably load-bearing.

Fixes #8192

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 4:08am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 04:23
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 2c81b92 Aug 13, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8192-pin-credential-at-rest-posture branch August 13, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing pins the credential-at-rest storage posture for scim_token / client_secret — the SCIM plugin's own default is cleartext

2 participants