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
16 changes: 16 additions & 0 deletions .changeset/six-plugins-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@opensaas/stack-auth': minor
---

BREAKING (pre-1.0): The derived auth lists' credential-bearing fields now ship with a field-level `read` deny, so opening operation-level access to a list no longer exposes them:

- `Session.token`
- `Verification.value`
- `Account.password`
- `Account.accessToken`
- `Account.refreshToken`
- `Account.idToken`

A denied field is stripped from a returned row, not an error — a `context.db` read on an opened list still succeeds and returns every other field, including a `findUnique` lookup that selects the row **by** the denied field itself (e.g. `context.db.session.findUnique({ where: { token } })` still finds the session; the returned `token` comes back stripped). Naming a denied field in `findMany`'s (or `count`'s) `where`/`orderBy` is different: the existing predicate-time read-access check (`validateQueryFieldReadAccess`) throws a `ValidationError` there instead, the same as it already does for any other field-level `read` deny. `sudo()` bypasses both — it's the supported path for an application with a genuine need. Sign-in, session refresh, email verification, and password reset are unaffected — better-auth's own flows write through the raw Prisma adapter, bypassing access control entirely.

If your application opens one of these lists today and deliberately reads one of these fields through `context.db` — a returned row, a `findMany`/`count` predicate, or a `findUnique` selector — switch that access to `context.sudo().db...`. See ADR-0036.
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ jobs:
env:
RUN_MCP_OAUTH_CASCADE_E2E: '1'

# Live proof that the derived auth lists' credential-bearing fields
# (Session.token, Verification.value, Account.password/accessToken/
# refreshToken/idToken) are stripped from an opened context.db read,
# remain readable under sudo(), and that sign-up/sign-in/session-refresh/
# password-reset are unaffected (issue #981, ADR-0036). Same offline
# toolchain/pattern as the guards above. Opt-in via
# RUN_CREDENTIAL_DENY_E2E. See
# packages/auth/tests/credential-field-read-deny-e2e.test.ts.
- name: Run credential-field read-deny e2e guard
run: pnpm --filter @opensaas/stack-auth test credential-field-read-deny-e2e
env:
RUN_CREDENTIAL_DENY_E2E: '1'

- name: Install Playwright browsers
uses: ./.github/actions/playwright

Expand Down
46 changes: 41 additions & 5 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ The auth plugin automatically generates the following lists:
- `id` (String, auto-generated)
- `userId` (String, foreign key to User)
- `expiresAt` (DateTime)
- `token` (String, unique)
- `token` (String, unique — **read-denied**, see below)
- `ipAddress` (String, optional)
- `userAgent` (String, optional)
- `createdAt` (DateTime, auto)
Expand All @@ -409,10 +409,10 @@ Stores OAuth provider information and password hashes:
- `userId` (String, foreign key to User)
- `accountId` (String, provider-specific user ID)
- `providerId` (String, e.g., 'github', 'google')
- `accessToken` (String, optional)
- `refreshToken` (String, optional)
- `accessToken` (String, optional — **read-denied**, see below)
- `refreshToken` (String, optional — **read-denied**, see below)
- `expiresAt` (DateTime, optional)
- `password` (String, optional, hashed)
- `password` (String, optional, hashed — **read-denied**, see below)
- `createdAt` (DateTime, auto)
- `updatedAt` (DateTime, auto)

Expand All @@ -422,11 +422,47 @@ Stores email verification and password reset tokens:

- `id` (String, auto-generated)
- `identifier` (String, email address)
- `value` (String, token)
- `value` (String, token — **read-denied**, see below)
- `expiresAt` (DateTime)
- `createdAt` (DateTime, auto)
- `updatedAt` (DateTime, auto)

### Credential fields are read-denied (ADR-0036)

`Session.token`, `Verification.value`, and `Account.password`/`accessToken`/`refreshToken`/`idToken`
hold live, presentable credentials — reading one is equivalent to holding it (session hijack, account
takeover, replaying an OAuth token). The plugin sets a field-level `read` deny on each of them when it
derives the list, so granting operation-level access to a list (e.g. `access: { session: { operation:
{ query: () => true } } }` for a "your active sessions" screen) does **not** also expose the token
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.

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
`context.db.session.findUnique({ where: { token } })` still finds and returns the session, just with
`token` stripped from the result like any other read.

`sudo()` bypasses both — it is the supported path for an application with a genuine need:

```typescript
// An admin tool that must inspect a live session token, filter sessions BY
// token, or an auth implementation verifying a password hash — all bypass
// the deny deliberately.
const session = await context.sudo().db.session.findUnique({ where: { token } })
session.token // present
```

The deny is keyed to better-auth's own model/field, not the app's list key or column name, so it
still applies after a `modelName` remap (`session: { modelName: 'AuthSession' }`) or a column
override (`session: { fields: { token: 'session_token' } } }`). Every other Auth list field —
identifiers, timestamps, `ipAddress`/`userAgent`, `providerId`/`accountId`, every `User` field — stays
open to whatever operation-level access you grant.

Better-auth's own sign-in/sign-up/session-refresh/password-reset flows are unaffected: they write and
read through the raw Prisma adapter, never through the access-controlled `context.db` these denies
gate.

### RateLimit

Only present when `rateLimit.storage: 'database'` is set — mirrors better-auth's own rate-limit table exactly:
Expand Down
4 changes: 4 additions & 0 deletions examples/starter/opensaas.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export default config({
}),
password: password({
validation: { isRequired: true },
// The hash is never needed on an ordinary read path — an auth
// implementation calls `.compare()` through its own `context.sudo()`
// read instead (see ADR-0036 in the OpenSaaS Stack repo).
access: { read: () => false },
}),
posts: relationship({
ref: 'Post.author',
Expand Down
34 changes: 34 additions & 0 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,40 @@ an app that needs to grant access declares the list itself under the same
derived key so the plugin's field-only extend path merges in (its own access
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
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
the session; `token` just comes back stripped) — `findUnique`'s `where` is
a unique selector, not a predicate the read-access check walks. Naming the
field in `findMany`'s (or `count`'s) `where`/`orderBy` instead takes the
predicate-time path (`validateQueryFieldReadAccess` in
`packages/core/src/access/query-validation.ts`) and throws a
`ValidationError` up front rather than stripping anything; `sudo()` is
required for that shape too, not only for reading the column back off a
row fetched another way.

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
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).

### Schema placement (relocatable Auth lists)

A plugin-level `schema` option places all generated Auth lists in a non-`public`
Expand Down
34 changes: 31 additions & 3 deletions packages/auth/src/config/derive-auth-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { getAuthTables } from 'better-auth/db'
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
import type { DBFieldAttribute } from 'better-auth/db'
import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
import type { ListConfig, FieldConfig, FieldAccess } from '@opensaas/stack-core'
import type { RelationshipField } from '@opensaas/stack-core/fields'
import type { ExtendUserListConfig } from '../lists/index.js'
import type { AuthAccessConfig, NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
Expand Down Expand Up @@ -100,6 +100,26 @@ const FIELD_ORDER: Partial<Record<BaseModelKey, string[]>> = {
/** Carried via list-level `db.timestamps` (see `listDb`) rather than as ordinary derived fields. */
const TIMESTAMP_FIELDS = new Set(['createdAt', 'updatedAt'])

/**
* Fields that hold a live, presentable credential — reading the value is
* equivalent to holding it (session hijack, account takeover, replaying an
* 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).
*/
const CREDENTIAL_FIELDS: Partial<Record<BaseModelKey, readonly string[]>> = {
session: ['token'],
verification: ['value'],
account: ['password', 'accessToken', 'refreshToken', 'idToken'],
}

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
return { ...field, access: DENY_READ }
}

/**
* Whether a model declares BOTH `createdAt` and `updatedAt` upstream — the
* only shape `db.timestamps: true` can express, since it always emits both
Expand Down Expand Up @@ -508,10 +528,18 @@ export function deriveAuthLists(
// relation can't express without pointing Prisma at the wrong
// column. Left as a plain scalar column, same as pre-consolidation
// behavior (issue #992).
;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField(fieldKey, upstream)
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
modelKey,
fieldKey,
buildScalarField(fieldKey, upstream),
)
}
} else {
;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField(fieldKey, upstream)
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
modelKey,
fieldKey,
buildScalarField(fieldKey, upstream),
)
}
}
}
Expand Down
Loading
Loading