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
23 changes: 23 additions & 0 deletions .changeset/plugin-table-credential-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@opensaas/stack-auth': minor
---

Extend the ADR-0036 credential field read-deny from the four base Auth models to better-auth plugin tables, and add a `credentialFields` config option for plugins the stack doesn't seed a set for.

The following fields now ship field-level `read`-denied, on top of the existing `Session.token`/`Verification.value`/`Account.password`/`accessToken`/`refreshToken`/`idToken`:

- `oauthClient.clientSecret`, `oauthAccessToken.token`, `oauthRefreshToken.token` (the `mcp`/oauth-provider plugin)
- `twoFactor.secret`, `twoFactor.backupCodes` (`twoFactor()`)

An application opening one of these lists (e.g. declaring `OauthClient` under its own `lists` to grant access) no longer also exposes the credential column — same behavior as the existing base-model deny, `sudo()` still reads it.

For a plugin the stack has no seeded credential set for, mark a field yourself:

```typescript
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: { passkey: ['publicKey'] },
})
```

`credentialFields` is additive only — it can add fields to any model (including a seeded one) but can never unmark a seeded field. An entry naming a field missing from a model your app actually derives throws at config time; an entry for a model your app doesn't derive is a no-op.
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ Concretely:
- This absorbs #994 (which had specified the same FK fix against list-only refs, before ADR-0033 shipped the base models' bidirectional-ref convention) — the FK column name is controllable on a bidirectional ref already, so #994's premise (list-only refs as the only viable shape) no longer applies, and its fix is superseded by this consolidation rather than landed separately.

See issue #992 for the full triage history, including the confirmed orphaning and the correction that bidirectional refs were viable all along.

Because plugin tables derive through this same registry and scalar-field-derivation pass, they are also in scope for ADR-0036's credential field read-deny — see that ADR (as amended by issue #1014) for the plugin-table credential set and the `credentialFields` seam.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Status: accepted

A field on a plugin-derived list that holds a **live, presentable credential** — one where reading the value is equivalent to holding it — ships with a field-level `read` deny that the plugin sets when it creates the list. Granting operation-level access to such a list does not grant access to those fields. For the auth plugin this covers `Session.token`, `Verification.value`, `Account.password`, and the OAuth token columns (`accessToken`, `refreshToken`, `idToken`).
A field on a plugin-derived list that holds a **live, presentable credential** — one where reading the value is equivalent to holding it — ships with a field-level `read` deny that the plugin sets when it creates the list. Granting operation-level access to such a list does not grant access to those fields. For the auth plugin this covers `Session.token`, `Verification.value`, `Account.password`, and the OAuth token columns (`accessToken`, `refreshToken`, `idToken`) on the four **base** models, and — as of issue #1014 — the same rule over **better-auth plugin tables** derived through the same registry (ADR-0034): `oauthClient.clientSecret`, `oauthAccessToken.token`, `oauthRefreshToken.token` (the `mcp`/oauth-provider plugin) and `twoFactor.secret`/`twoFactor.backupCodes` (`twoFactor()`). An application can mark further plugin fields as credentials via `authPlugin({ credentialFields })`, additive only over the stack's seeded set.

## Context

Expand All @@ -29,3 +29,4 @@ Two properties make the deny cheap. `sudo()` skips field-level access checks, so
- **A denied field is stripped, not an error.** It follows the field-level access path's existing behaviour; a list read still succeeds and returns every other field. The application sees a missing column, not a failure.
- **`sudo()` still reads these fields.** That is the supported path for an application with a legitimate need until a narrower seam exists, and it is what the auth package's own helpers already use.
- **The rule is about the credential, not the list.** A derived field that merely identifies (`Session.ipAddress`, `Account.providerId`) stays open. The test is whether reading the value confers the ability to act as someone — that is what earns a deny.
- **The rule applies uniformly to plugin tables, keyed by better-auth's own model/field keys — not by whatever store mode the value happens to sit in.** `oauth-provider`'s token/secret columns default to hashed storage (encrypted when `disableJwtPlugin`) and `twoFactor`'s `secret`/`backupCodes` are stored encrypted; the deny does not condition on `storeTokens`/`storeClientSecret` or any other at-rest mode, and it is not derived from better-auth's own `returned: false` flag — that flag is both noisy (it also marks plain identifiers, e.g. `twoFactor.userId`) and incomplete (`oauth-provider` never sets it). The stack seeds the set for the plugins it has first-class support for; `authPlugin({ credentialFields })` lets an application extend it to a plugin the stack has never seen, additive-only over the seeded set (issue #1014).
42 changes: 42 additions & 0 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,19 @@ authPlugin({

The auth plugin automatically converts Better Auth plugin schemas to OpenSaaS lists.

### `credentialFields`

Mark additional better-auth model fields as credentials, so they ship field-level read-denied
alongside the stack's own seeded set. See [Credential fields are read-denied](#credential-fields-are-read-denied-adr-0036)
below for the full contract and the seeded set.

```typescript
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: { passkey: ['publicKey'] },
})
```

### `betterAuthOptions`

Escape hatch for any better-auth option the stack doesn't model as its own config field. Deep-merged into the options `createAuth()` builds, applied **last** — a plain-object value at a given key merges recursively with what the stack already set there (so a nested addition like `session.cookieCache` adds alongside the stack's own `session.expiresIn`/`updateAge` rather than replacing them), and on a genuine key collision `betterAuthOptions` wins. Arrays and any other value type replace the stack's value outright.
Expand Down Expand Up @@ -439,6 +452,35 @@ derives the list, so granting operation-level access to a list (e.g. `access: {
column — the field is silently stripped from a returned row, the same as any other field-level read
denial, and the rest of the row is returned normally.

The same deny covers **better-auth plugin table** credential fields for the plugins the stack has
first-class support for — plugin tables derive through the identical field-derivation pass as the
base models (ADR-0034):

| Model (better-auth key) | Field(s) | Plugin |
| ----------------------- | ----------------------- | ---------------------- |
| `oauthClient` | `clientSecret` | `mcp` / oauth-provider |
| `oauthAccessToken` | `token` | `mcp` / oauth-provider |
| `oauthRefreshToken` | `token` | `mcp` / oauth-provider |
| `twoFactor` | `secret`, `backupCodes` | `twoFactor()` |

For any other plugin, mark a field as a credential yourself via `credentialFields` — keyed by
better-auth's own **model key** (not the derived list key) and naming better-auth's own **field
keys** (not mapped column names). It is strictly additive: it can mark further fields, but can never
unmark one of the fields above.

```typescript
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: {
passkey: ['publicKey'],
},
})
```

An entry naming a field that doesn't exist on a model your app actually derives (the plugin is
registered) throws at config time, naming the model and field; an entry for a model your app doesn't
derive at all (the plugin isn't registered) is a silent no-op.

Naming a denied field in `findMany`'s (or `count`'s) `where`/`orderBy` is different: that's rejected
up front with a `ValidationError` rather than silently stripped, the same as any other field-level
`read` deny. A `findUnique` lookup is not — its `where` only unique-selects the row, so
Expand Down
62 changes: 47 additions & 15 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,11 @@ then stands, same as any other `extendList`).
### Credential fields are read-denied independent of operation access (ADR-0036)

Operation-level access is all-or-nothing at the list, not the column — so
the deny above (list-level) isn't the whole story for the six
credential-bearing fields (`Session.token`, `Verification.value`,
`Account.password`/`accessToken`/`refreshToken`/`idToken`): `deriveAuthLists`
sets a field-level `access: { read: () => false }` on each of them
unconditionally, independent of whatever `accessConfig` an app supplies.
Opening `query` on `Session` for a "your active sessions" screen no longer
also exposes `token` — the field is stripped from a returned row (the
the deny above (list-level) isn't the whole story for a credential-bearing
field: `deriveAuthLists` sets a field-level `access: { read: () => false }`
on each one unconditionally, independent of whatever `accessConfig` an app
supplies. Opening `query` on `Session` for a "your active sessions" screen no
longer also exposes `token` — the field is stripped from a returned row (the
ordinary field-access-denial behavior), the row itself still returns. This
holds even for a `findUnique` lookup that selects the row BY the denied
field (`context.db.session.findUnique({ where: { token } })` still finds
Expand All @@ -214,17 +212,51 @@ predicate-time path (`validateQueryFieldReadAccess` in
required for that shape too, not only for reading the column back off a
row fetched another way.

This is not a closed list of six base-model fields — it also covers **plugin
table** credential fields the stack has first-class support for (ADR-0034),
since a plugin table derives through the same scalar-field derivation pass:

| Model (better-auth key) | Field(s) | Source |
| ----------------------- | ---------------------------------------------------- | ---------------------- |
| `session` | `token` | base |
| `verification` | `value` | base |
| `account` | `password`, `accessToken`, `refreshToken`, `idToken` | base |
| `oauthClient` | `clientSecret` | `mcp` / oauth-provider |
| `oauthAccessToken` | `token` | `mcp` / oauth-provider |
| `oauthRefreshToken` | `token` | `mcp` / oauth-provider |
| `twoFactor` | `secret`, `backupCodes` | `twoFactor()` |

The deny is applied in the scalar-field derivation loop
(`withCredentialAccess` in `derive-auth-lists.ts`), keyed by better-auth's
own model/field key (`CREDENTIAL_FIELDS`) — not the app's list key or
column `db.map` — so it survives a `modelName` remap or a `fields` column
override. `sudo()` still reads these fields either way, unaffected: this is
the supported path for a genuine need (an admin tool, or an app's own auth
(`withCredentialAccess` in `derive-auth-lists.ts`), against a registry built
by `buildCredentialFieldRegistry` — the stack-seeded `CREDENTIAL_FIELDS` table
above merged with an app's `authPlugin({ credentialFields })` — keyed by
better-auth's own model/field key, not the app's list key or column `db.map`,
so it survives a `modelName` remap or a `fields` column override either way.
`credentialFields` (`Record<better-auth model key, field key[]>`) is how an
app marks a credential field on a better-auth plugin the stack doesn't seed a
set for; it is **additive only** — it can add fields to any model, including a
seeded one, but can never unmark a seeded field. An entry naming a field
absent from a model the app actually derives (that plugin is registered)
throws, naming the model and field; an entry for a model the app doesn't
derive at all is a silent no-op:

```typescript
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: { passkey: ['publicKey'] },
})
```

`sudo()` still reads every one of these fields either way, unaffected: this
is the supported path for a genuine need (an admin tool, or an app's own auth
code verifying a password hash via `HashedPassword.compare()`). See
`packages/core/CLAUDE.md`'s "Access Control Execution Flow" for how a
field-level `read` denial is enforced, and ADR-0036 for why this list is
exactly these six fields and not, say, `Account.providerId` or
`Session.ipAddress` (identifying, not authenticating — left open).
field-level `read` denial is enforced, and ADR-0036 for why the rule is about
whether reading the value confers a live credential, not, say,
`Account.providerId` or `Session.ipAddress` (identifying, not authenticating
— left open) — and it does not condition on better-auth's own storage mode
(`storeTokens`/`storeClientSecret`) or `returned: false` flag, which is
neither a reliable nor a complete signal (see ADR-0036).

### Schema placement (relocatable Auth lists)

Expand Down
90 changes: 87 additions & 3 deletions packages/auth/src/config/derive-auth-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,94 @@ const TIMESTAMP_FIELDS = new Set(['createdAt', 'updatedAt'])
* OAuth token) — rather than merely identifying a row. Keyed by better-auth's
* own model/field keys, not the app's list/column names, so the deny holds
* under `modelName` and column `fields` remapping alike (ADR-0036).
*
* Covers the four base models plus every better-auth plugin table the stack
* has first-class support for (ADR-0034's registry of known plugins) — the
* `mcp`/oauth-provider plugin's client secret and token columns, and
* `twoFactor()`'s encrypted secret/backup codes (issue #1014). An app can
* mark further fields via `authPlugin({ credentialFields })`, merged in by
* {@link buildCredentialFieldRegistry} — this constant is never mutated.
*/
const CREDENTIAL_FIELDS: Partial<Record<BaseModelKey, readonly string[]>> = {
const CREDENTIAL_FIELDS: Record<string, readonly string[]> = {
session: ['token'],
verification: ['value'],
account: ['password', 'accessToken', 'refreshToken', 'idToken'],
oauthClient: ['clientSecret'],
oauthAccessToken: ['token'],
oauthRefreshToken: ['token'],
twoFactor: ['secret', 'backupCodes'],
}

const DENY_READ: FieldAccess = { read: () => false }

function withCredentialAccess(modelKey: string, fieldKey: string, field: FieldConfig): FieldConfig {
if (!CREDENTIAL_FIELDS[modelKey as BaseModelKey]?.includes(fieldKey)) return field
/** Every better-auth model/field key marked as a credential — the stack's own {@link CREDENTIAL_FIELDS} plus an app's `credentialFields`. */
type CredentialFieldRegistry = Record<string, ReadonlySet<string>>

/**
* Merges the stack-seeded {@link CREDENTIAL_FIELDS} with an app's
* `authPlugin({ credentialFields })`, then validates every entry against the
* models actually being derived (`tables`, keyed by better-auth model key).
*
* Additive only: an app entry can add fields to a model — including a
* stack-seeded one — but nothing can remove a seeded field, so a config that
* omits or empties a seeded model's list leaves that model's seeded deny
* standing.
*
* A field named on a model that isn't in `tables` at all (a plugin the app
* doesn't use) is a silent no-op — the entry simply never matches anything.
* A field named on a model that IS in `tables` but doesn't declare that field
* throws, naming the model and field, since that is a config mistake the app
* would otherwise never learn about (the deny would just never fire).
*/
function buildCredentialFieldRegistry(
tables: Record<string, ResolvedTable>,
appConfig: Record<string, string[]>,
): CredentialFieldRegistry {
const merged = new Map<string, Set<string>>()
for (const [modelKey, fields] of Object.entries(CREDENTIAL_FIELDS)) {
merged.set(modelKey, new Set(fields))
}
for (const [modelKey, fields] of Object.entries(appConfig)) {
const set = merged.get(modelKey) ?? new Set<string>()
for (const fieldKey of fields) set.add(fieldKey)
merged.set(modelKey, set)
}

const registry: CredentialFieldRegistry = {}
for (const [modelKey, fields] of merged) {
const table = tables[modelKey]
if (!table) continue // model not derived (plugin unused) -> silent no-op
for (const fieldKey of fields) {
const upstream = table.fields[fieldKey]
if (!upstream) {
throw new Error(
`deriveAuthLists: credentialFields names "${modelKey}.${fieldKey}", but "${modelKey}" has no field "${fieldKey}"`,
)
}
// An id-referencing field derives to a relationship() (see the
// `references.field === 'id'` branch below), never a scalar field —
// withCredentialAccess is only ever applied on the scalar-field path,
// so a deny registered against one would silently never apply. Fail
// loudly instead of accepting a config that has no effect.
if (upstream.references?.field === 'id') {
throw new Error(
`deriveAuthLists: credentialFields names "${modelKey}.${fieldKey}", but "${fieldKey}" is a ` +
`relationship field (references "${upstream.references.model}.id"), not a scalar credential column`,
)
}
}
registry[modelKey] = fields
}
return registry
}

function withCredentialAccess(
registry: CredentialFieldRegistry,
modelKey: string,
fieldKey: string,
field: FieldConfig,
): FieldConfig {
if (!registry[modelKey]?.has(fieldKey)) return field
return { ...field, access: DENY_READ }
}

Expand Down Expand Up @@ -493,19 +570,24 @@ function buildModelRegistry(
* @param accessConfig - App-authored access for each base Auth list, keyed by better-auth model name
* @param plugins - The app's better-auth plugins (`authPlugin({ betterAuthPlugins })`), whose own
* `schema` (base-model extensions and standalone plugin tables) is derived alongside the base models
* @param credentialFieldsConfig - App-authored additions to the credential-field read-deny
* (`authPlugin({ credentialFields })`), keyed by better-auth model key. Additive only — see
* {@link buildCredentialFieldRegistry}.
* @returns The derived base-model list keys and every derived list config (base models and plugin tables)
*/
export function deriveAuthLists(
models: NormalizedAuthModels,
userConfig: ExtendUserListConfig = {},
accessConfig: AuthAccessConfig = {},
plugins: BetterAuthPlugin[] = [],
credentialFieldsConfig: Record<string, string[]> = {},
): DerivedAuthLists {
const tables = getAuthTables(buildBetterAuthTableOptions(models, plugins)) as Record<
string,
ResolvedTable
>
const { keys, registry } = buildModelRegistry(tables, models)
const credentialRegistry = buildCredentialFieldRegistry(tables, credentialFieldsConfig)

// Only the five base models carry an app-authored `db.indexes` passthrough
// (`AuthModelConfig.indexes`) — plugin tables have no per-model config
Expand Down Expand Up @@ -577,6 +659,7 @@ export function deriveAuthLists(
// column. Left as a plain scalar column, same as pre-consolidation
// behavior (issue #992).
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
credentialRegistry,
modelKey,
fieldKey,
buildScalarField(
Expand All @@ -588,6 +671,7 @@ export function deriveAuthLists(
}
} else {
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
credentialRegistry,
modelKey,
Comment thread
borisno2 marked this conversation as resolved.
fieldKey,
buildScalarField(
Expand Down
Loading
Loading