Skip to content

fix(security): sys_account OAuth access/refresh/id tokens stop serializing on the data API (#7987) - #8675

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-7987-sys-account-token-columns
Aug 14, 2026
Merged

fix(security): sys_account OAuth access/refresh/id tokens stop serializing on the data API (#7987)#8675
qq9340100 merged 1 commit into
mainfrom
claude/issue-7987-sys-account-token-columns

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes#7987

sys_account.access_token, .refresh_token and .id_token hold each user's live third-party OAuth credentials — the tokens ObjectStack received from Google, GitHub or an OIDC IdP — in cleartext, on an object declaring apiEnabled: true, apiMethods: ['get','list']. They are declared internal: true here, and better-auth's readback seam is widened so its own token routes keep working.

The card's load-bearing question, measured

The card and both PM comments parked this on one question: does any better-auth login/refresh path read these values off a result row? It does — traced in better-auth/dist/api/routes/account.mjs against the pinned better-auth@1.7.0-rc.2:

  • internalAdapter.findAccounts(userId) issues findMany with no projection, so the row set is exactly what the read strip empties;
  • resolveUserAccount() picks a row out of it, and getValidAccessToken() — behind /get-access-token and /account-info — then reads account.refreshToken to decide whether to refresh, account.accessToken to answer with, and account.idToken to carry forward;
  • POST /refresh-token reads account.refreshToken and answers REFRESH_TOKEN_NOT_FOUND (400) when it is absent.

This is not a fork, because it is the shape PR #7996 already landed a mechanism for: the adapter-level readback through Engine.resolveInternalField (#8118). The card was parked when that mechanism did not exist yet; it does now, and internal-field-readback.ts says in its own header that widening it to sys_account was #7987's call.

Both personas measured leaking, on a real booted stack

bootStack(showcaseStack), in-process HTTP + sqlite-wasm, with a token planted on a member's account row, run against a build differing from this PR only in the three flags:

[ablation] admin get-by-id keys: id,created_at,updated_at,provider_id,issuer,account_id,
user_id,access_token,refresh_token,id_token,access_token_expires_at,
refresh_token_expires_at,scope,password,previous_password_hashes
[ablation] admin sees refresh_token = 1//PLANTED-REFRESH-TOKEN-7987
[ablation] member sees own refresh_token = 1//PLANTED-REFRESH-TOKEN-7987
  • admin, GET /data/sys_account/{another user's account id} — 200, that member's refresh token verbatim;
  • member, GET /data/sys_account — 200, their own refresh token verbatim, granted by the sys_account_self RLS policy (select on user_id == current_user.id).

The member arm has no analogue in #7823 and is the sharper of the two: it converts a short-lived, revocable ObjectStack session bearer into a long-lived third-party refresh token that this platform cannot revoke at all. That also answers triage's severity question (comment 5265158443, verification 2): these rows are reachable for a plain non-admin persona — for their own row.

Neither collector reached these columns: maskSecretFields collects by field TYPE (textarea is neither secret nor password) and exempts objects with managedBy: 'better-auth', which this object is. Two independent reasons, as the card's survey said.

What changed

  • packages/platform-objects/src/identity/sys-account.object.ts — the three columns get internal: true plus a description recording the contract. Not retyped to Field.secret() (better-auth owns the writes; the engine would sit between it and its own adapter) and Field.password() is inert here — both per the card's accepted argument.
  • packages/plugins/plugin-auth/src/session-token-readback.tsinternal-field-readback.ts — the existing seam, widened from one column to a per-object table and renamed to match. ⚠️The packages/objectql accessor is CONSUMED, not restructured — no signature change, so the held-out sibling [security] sys_email.headers_json stores custom headers cleartext — same shape as sys_http_delivery; adopt whatever remedy #8118 lands, do not decide it twice #8149 is unaffected. The accessor resolves one field per call by contract, so three columns cost three id-batched driver reads per page; widening it to a field set would restructure a surface other consumers share, to save two indexed point-reads on a path dominated by the provider round trip and the password KDF.

The one non-obvious decision: fail-closed posture is per column

#7823's seam treats "the key is missing" as proof the strip ran, and throws when the engine offers no accessor. That is sound for sys_session.token, which is required: true — a session row without a token does not exist.

It is false for these three, which are required: false and are genuinely empty on every credential (password) account. Carrying the rule over broke ordinary sign-in against the in-memory fake engines — measured: 16 red tests across session-of-record, session-tombstone and impersonation-bearer-rotation, because findCredentialAccount reads exactly such a row on the sign-in path. So each column now declares whether its absence is diagnostic (absenceProvesStrip), and the session column's fail-closed contract is pinned by its own test so a future edit cannot quietly take it down with the account columns.

The residual risk that buys is a version-skewed engine that strips but predates #8118 — which for these columns degrades loudly (a 400 from the token routes), where the session column would have degraded into a security control silently reporting success.

Verification

Run at 011d9cd4, the head of this branch, with a clean tree.

The fixture is proven discriminating, in both directions

A credential-account row has all three columns empty, so "the response has no refresh_token key" would be true whether or not the fix exists. The fixture therefore plants real values and re-reads them out of storage through the privileged accessor under assertArmed (#8074) before asserting anything.

Ablation (internal: false on the three columns, platform-objects rebuilt, scripts/ablation-dist-preflight.mjs confirming the marker reached dist/ — and confirming with --absent that it left again on restore):

  • predicted RED, observed RED — the fixture fails in beforeAll as DISARMED, because resolveInternalField refuses a column that is not flagged;
  • and a temporary leak probe, run on that same ablated build, passed — i.e. the leak reproduces exactly as quoted above. That probe was deleted before commit.

Restored and rebuilt, the fixture is green again (8/8).

Notes

  • Base: rebased onto 4bfe1a539, which carries 247c55ad3 — the fix for the post-GA .changeset self-test failures that are red on the older base. Both of those gates are green at this head; nothing in this diff addresses them.
  • File surface: the dispatch named packages/platform-objects plus a seam in packages/objectql/src. The landed precedent's seam is in packages/plugins/plugin-auth/src, not objectql — that is the identity authority's own storage seam, and where Check whether sys_session.token — a live session credential — serializes over the data API (ADR-0100 channel 3 has no read protection) #7823 deliberately put the privileged dereference to keep the engine's read path carve-out-free. Reported rather than silently taken.
  • Out of scope, filed separately: the key list above also shows password and previous_password_hashes serializing on the same response. They are one-way hashes (ADR-0100's third channel), explicitly carved out by this card, and are not touched here.

Generated by Claude Code

…API (#7987)
`sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's
live third-party OAuth credentials in cleartext, on an object declaring
`apiEnabled: true, apiMethods: ['get','list']`. Measured leaking on a real
booted stack in BOTH personas: an admin read another member's refresh token
verbatim by id, and a member read their own off the self-scoped list — the arm
`sys_session` does not have, and the sharper one, since it converts a
revocable session bearer into a long-lived token this platform cannot revoke.
Declares the three columns `internal: true` (#7728's flag, #7823's shape) and
widens plugin-auth's readback seam to cover them: better-auth reads these back
off adapter result rows (`findAccounts` feeds /get-access-token,
/account-info and /refresh-token), so the read strip alone would answer
REFRESH_TOKEN_NOT_FOUND and hand back an empty access token. Recovered through
`Engine.resolveInternalField` (#8118) — no engine carve-out, no second
accessor.
`password` / `previous_password_hashes` stay out of scope per the card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 12:56pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx(via @objectstack/platform-objects, @objectstack/plugin-auth)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 14, 2026 13:53
@qq9340100
qq9340100 added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit c9f5950Aug 14, 2026
28 checks passed
@qq9340100
qq9340100 deleted the claude/issue-7987-sys-account-token-columns branch August 14, 2026 14:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] sys_account stores live third-party OAuth access/refresh/id tokens as plain columns, and the object is API-readable

2 participants

@qq9340100@claude