Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .changeset/account-oauth-tokens-internal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/platform-objects": patch
"@objectstack/plugin-auth": patch
---

fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987)

<!-- adr-0087: not-required (no-migration-prescription) Three field-level flags
added to one existing declaration, plus a rename and a widening of a
plugin-internal helper module (`session-token-readback.ts` →
`internal-field-readback.ts`, not an exported surface of the package). Nothing
authorable is renamed, retired or tombstoned, so there is no conversion to
register. The behavioural change is that three columns holding someone else's
live bearer credentials stop being returned on the generic data path, while
better-auth's own token routes keep working. -->

`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 (better-auth's
`account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them
verbatim). They were plain `Field.textarea` on an object declaring
`apiEnabled: true, apiMethods: ['get','list']`.

**Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`,
in-process HTTP + sqlite-wasm), with a planted token on a member's account row:

- **admin**, `GET /data/sys_account/{another user's account id}` — 200, that
member's `refresh_token` verbatim, plus `access_token` and `id_token`;
- **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS
policy) — 200, their **own** `refresh_token` verbatim.

The member arm is the one this object does not share with its `sys_session`
sibling (#7823), and it 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**. Neither collector reached these
columns — the engine's credential mask collects by field TYPE (`textarea` is
neither `secret` nor `password`) *and* exempts objects with
`managedBy: 'better-auth'`, which this object is.

**The fix is three declarations plus one widening**, inheriting #7823's shape
rather than inventing a second mechanism:

- the three columns are declared `internal: true` — the opt-in, type-independent
flag minted by #7728 meaning *the declared value is never returned on the
generic data path*. Storage, filtering and indexing are untouched: the strip
runs on rows the driver has already produced.
- better-auth **reads these back off adapter result rows** — measured, and the
risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a
`findMany` with no projection, and `/get-access-token`, `/account-info` and
`/refresh-token` then read `account.refreshToken` / `.accessToken` /
`.idToken` off those rows. The read strip alone would answer
`REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the
existing readback seam in `@objectstack/plugin-auth` — which already recovered
`sys_session.token` through `Engine.resolveInternalField` (#8118's privileged
batch accessor) — is widened to cover these three columns and renamed
accordingly. No engine carve-out, no second accessor.

**Not retyped, deliberately.** `Field.secret()` would route better-auth's own
writes through the engine's encrypt-on-write path, placing the engine between
better-auth and its own adapter. `Field.password()` is inert here for the two
reasons above.

**`password` / `previous_password_hashes` are deliberately out of scope** —
they are better-auth one-way hashes (ADR-0100's third channel), not reversible
outbound credentials, and the readback seam refuses to touch them.

The regression proof drives both directions: the fixture PLANTS real token
values and re-reads them out of storage through the privileged accessor before
asserting anything (so "absent from the response" cannot pass vacuously), then
pins that the values are still on disk, still usable as a server-side predicate,
and that password sign-in — which reads a `sys_account` row back through the
same seam on every request — still works.
Original file line numberDiff line numberDiff line change
Expand Up@@ -398,13 +398,16 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
help: "Link to user table"
},
access_token: {
label: "Access Token"
label: "Access Token",
help: "Live OAuth access token issued by the provider — never returned on the data API (#7987); better-auth reads it back through the engine's privileged internal-field accessor"
},
refresh_token: {
label: "Refresh Token"
label: "Refresh Token",
help: "Live OAuth refresh token — long-lived and not revoked by revoking an ObjectStack session; never returned on the data API (#7987)"
},
id_token: {
label: "ID Token"
label: "ID Token",
help: "OIDC ID token issued by the provider — never returned on the data API (#7987)"
},
access_token_expires_at: {
label: "Access Token Expires At"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -398,13 +398,16 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
help: "Enlace a la tabla de usuarios."
},
access_token: {
label: "Token de acceso"
label: "Token de acceso",
help: "Live OAuth access token issued by the provider — never returned on the data API (#7987); better-auth reads it back through the engine's privileged internal-field accessor"
},
refresh_token: {
label: "Token de actualización"
label: "Token de actualización",
help: "Live OAuth refresh token — long-lived and not revoked by revoking an ObjectStack session; never returned on the data API (#7987)"
},
id_token: {
label: "Token de ID"
label: "Token de ID",
help: "OIDC ID token issued by the provider — never returned on the data API (#7987)"
},
access_token_expires_at: {
label: "El token de acceso caduca el"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -398,13 +398,16 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
help: "ユーザーテーブルへのリンク"
},
access_token: {
label: "アクセストークン"
label: "アクセストークン",
help: "Live OAuth access token issued by the provider — never returned on the data API (#7987); better-auth reads it back through the engine's privileged internal-field accessor"
},
refresh_token: {
label: "リフレッシュトークン"
label: "リフレッシュトークン",
help: "Live OAuth refresh token — long-lived and not revoked by revoking an ObjectStack session; never returned on the data API (#7987)"
},
id_token: {
label: "ID トークン"
label: "ID トークン",
help: "OIDC ID token issued by the provider — never returned on the data API (#7987)"
},
access_token_expires_at: {
label: "アクセストークン有効期限"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -398,13 +398,16 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
help: "关联到用户表"
},
access_token: {
label: "访问令牌"
label: "访问令牌",
help: "Live OAuth access token issued by the provider — never returned on the data API (#7987); better-auth reads it back through the engine's privileged internal-field accessor"
},
refresh_token: {
label: "刷新令牌"
label: "刷新令牌",
help: "Live OAuth refresh token — long-lived and not revoked by revoking an ObjectStack session; never returned on the data API (#7987)"
},
id_token: {
label: "ID 令牌"
label: "ID 令牌",
help: "OIDC ID token issued by the provider — never returned on the data API (#7987)"
},
access_token_expires_at: {
label: "Access Token 过期时间"
Expand Down
57 changes: 55 additions & 2 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,19 +178,72 @@ export const SysAccount = ObjectSchema.create({
description: 'Link to user table',
}),

// ── Live third-party credentials (never on the generic data path) ──────
//
// [#7987] These three columns hold the user's LIVE bearer credentials for
// SOMEONE ELSE'S service — the tokens ObjectStack received from Google,
// GitHub, an OIDC IdP — in cleartext. better-auth writes them plain here:
// its `account.encryptOAuthTokens` option is not set (see
// `AUTH_ACCOUNT_CONFIG` in plugin-auth's `auth-schema-config.ts`), so
// `setTokenUtil` stores the value verbatim.
//
// Before `internal: true` they serialized on the generic data path, on an
// object that declares `apiEnabled: true, apiMethods: ['get','list']`:
//
// - an ADMIN read every user's tokens (list, get-by-id, `?select=`);
// - a MEMBER read their own, because the `sys_account_self` RLS policy
// (`plugin-security/objects/default-permission-sets.ts`) grants
// `select` on `user_id == current_user.id`. That arm is the one this
// object does NOT share with `sys_session`: it converts a short-lived,
// revocable ObjectStack session bearer into the user's long-lived
// third-party REFRESH token, which survives revocation here entirely.
//
// Neither collector reached them: `maskSecretFields` collects by field
// TYPE (`textarea` is not `secret`/`password`) *and* exempts objects with
// `managedBy: 'better-auth'` — which this object is — so the one mask that
// could have applied was exempt by construction (#7902's survey result).
//
// `internal: true` is the same flag #7728 minted for `sys_api_key.key` and
// #7823 applied to `sys_session.token`: the engine OMITS the key from
// find/findOne results, on the default projection and when a client names
// the column in `?select=`. Storage, filtering and indexing are untouched.
//
// ⛔ NOT retyped to `Field.secret()`, deliberately. better-auth owns every
// write to this object through its own adapter; routing them through the
// engine's encrypt-on-write path would sit between better-auth and its
// adapter. `Field.password()` is inert for the two reasons above (type-keyed
// collection + the `better-auth` exemption).
//
// ⚠️ better-auth READS these back off adapter result rows — measured, and
// the load-bearing risk this card was parked on:
// `internalAdapter.findAccounts(userId)` (no projection) feeds
// `resolveUserAccount`, and `/get-access-token`, `/account-info` and
// `/refresh-token` then read `account.refreshToken` / `.accessToken` /
// `.idToken` off those rows. The read strip alone would make the refresh
// exchange answer `REFRESH_TOKEN_NOT_FOUND` and hand back an empty access
// token. They are re-attached at better-auth's own storage seam through
// the privileged accessor — `internal-field-readback.ts` in plugin-auth,
// over `Engine.resolveInternalField` (#8118) — exactly as #7823 did for
// `sys_session.token`. ⛔ Do not add an engine-side carve-out.
access_token: Field.textarea({
label: 'Access Token',
required: false,
internal: true,
description: "Live OAuth access token issued by the provider — never returned on the data API (#7987); better-auth reads it back through the engine's privileged internal-field accessor",
}),

refresh_token: Field.textarea({
label: 'Refresh Token',
required: false,
internal: true,
description: 'Live OAuth refresh token — long-lived and not revoked by revoking an ObjectStack session; never returned on the data API (#7987)',
}),

id_token: Field.textarea({
label: 'ID Token',
required: false,
internal: true,
description: 'OIDC ID token issued by the provider — never returned on the data API (#7987)',
}),

access_token_expires_at: Field.datetime({
Expand Down
Loading
Loading