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
22 changes: 22 additions & 0 deletions .changeset/warm-sessions-project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@opensaas/stack-auth': minor
'@opensaas/stack-cli': minor
---

Fix `getSessionFromAuth` to project `sessionFields` from the _resolved_ better-auth session instead of only its `user` sub-object. A `customSession` plugin's replaced shape with no `user` key is now correctly treated as a signed-in session (never misreported as anonymous), and a session-only field (e.g. the admin plugin's `impersonatedBy`) is now resolvable. Errors from the underlying session lookup now propagate instead of silently becoming `null`, and a `sessionFields` entry that can't be resolved is omitted and logs a warning (once per field, per process) instead of vanishing silently.

The scaffolded `getSession()` — the CLI feature generator's `lib/auth.ts` template, and `examples/starter-auth`/`examples/auth-demo` — now call this single shared helper, reading `sessionFields` from the resolved config at runtime instead of baking a field list in at generation time. `examples/auth-demo`'s `getSession()` also now correctly returns `null` for an anonymous visitor (previously returned a truthy object of `undefined` values).

```typescript
authPlugin({ sessionFields: ['userId', 'email', 'name', 'role'] })
```

```typescript
// lib/auth.ts
export async function getSession() {
const resolvedConfig = await config
const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
const sessionFields = authConfig?.sessionFields ?? ['userId', 'email', 'name']
return getSessionFromAuth(auth, sessionFields, await headers())
}
```
26 changes: 26 additions & 0 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ access: {
}
```

**`sessionFields` describes a flattened projection, not the session's own shape.** Each
name is resolved off the _resolved_ better-auth session (whatever `auth.api.getSession()`
returns) against a fixed precedence, so a collision between sources is predictable:

1. `userId` is special-cased to the authenticated user's `id` — the documented default.
2. Every other name resolves against the first hit in: a top-level key on the resolved
session object, then the `user` object, then the `session` sub-object. This is what makes
a session-only field (e.g. the admin plugin's `impersonatedBy`) reachable, not just fields
on the user.

A name that can't be resolved is omitted from the session and logs a warning (once per field,
per process) naming what was checked, instead of silently surfacing later as an access-control
function reading `undefined`.

A `customSession` better-auth plugin fully **replaces** the resolved session and can nest its
fields anywhere — e.g. under its own custom key. When that happens, `sessionFields` and the
actual resolved shape describe different things, and reconciling them (renaming, flattening a
nested value) is the application's job, not something `sessionFields` does automatically.

The scaffolded `getSession()` (`lib/auth.ts`) calls the exported `getSessionFromAuth()` helper
(`@opensaas/stack-auth/server`) with the config's resolved `sessionFields`, read at runtime —
changing `sessionFields` takes effect without regenerating `lib/auth.ts`. `getSessionFromAuth()`
returns `null` only when there is genuinely no session; a resolved session with no `user` key
(a `customSession` plugin that dropped it) is still a session and still gets projected. Errors
from the underlying session lookup propagate rather than becoming `null`.

### `extendUserList`

Add custom fields, access control, or hooks to the auto-generated User list:
Expand Down
23 changes: 11 additions & 12 deletions examples/auth-demo/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createAuth } from '@opensaas/stack-auth/server'
import { createAuth, getSessionFromAuth } from '@opensaas/stack-auth/server'
import type { NormalizedAuthConfig } from '@opensaas/stack-auth'
import type { Session } from '@opensaas/stack-core'
import config from '../opensaas.config'
import { headers } from 'next/headers'
import { rawOpensaasContext } from '@/.opensaas/context'
Expand All @@ -10,18 +12,15 @@ import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)

/**
* Get the current session in OpenSaas format
* Extracts configured sessionFields from Better Auth session
* Get the current session in OpenSaas format. Reads `sessionFields` from the
* resolved config at runtime, so changing it doesn't require regenerating
* this file. Returns `null` for an anonymous visitor.
*/
export async function getSession() {
const session = await auth.api.getSession({
headers: await headers(),
})
return {
userId: session?.user?.id,
email: session?.user?.email,
name: session?.user?.name,
}
export async function getSession(): Promise<Session | null> {
const resolvedConfig = await config
const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
const sessionFields = authConfig?.sessionFields ?? ['userId', 'email', 'name']
return getSessionFromAuth(auth, sessionFields, await headers())
}

/**
Expand Down
24 changes: 11 additions & 13 deletions examples/starter-auth/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createAuth } from '@opensaas/stack-auth/server'
import { createAuth, getSessionFromAuth } from '@opensaas/stack-auth/server'
import type { NormalizedAuthConfig } from '@opensaas/stack-auth'
import type { Session } from '@opensaas/stack-core'
import config from '../opensaas.config'
import { headers } from 'next/headers'
import { rawOpensaasContext } from '@/.opensaas/context'
Expand All @@ -10,19 +12,15 @@ import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)

/**
* Get the current session in OpenSaas format
* Extracts configured sessionFields from Better Auth session
* Get the current session in OpenSaas format. Reads `sessionFields` from the
* resolved config at runtime, so changing it doesn't require regenerating
* this file.
*/
export async function getSession() {
const session = await auth.api.getSession({
headers: await headers(),
})
if (!session || !session.user) return null
return {
userId: session.user.id,
email: session.user.email,
name: session.user.name,
}
export async function getSession(): Promise<Session | null> {
const resolvedConfig = await config
const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
const sessionFields = authConfig?.sessionFields ?? ['userId', 'email', 'name']
return getSessionFromAuth(auth, sessionFields, await headers())
}

/**
Expand Down
12 changes: 11 additions & 1 deletion packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ const context = createContext(config, prisma, session)

### Session Fields Configuration

Control which User fields appear in session:
`sessionFields` describes a **flattened projection** of the resolved better-auth session, not
the session's own shape:

```typescript
authPlugin({ sessionFields: ['userId', 'email', 'name', 'role'] })
Expand All @@ -247,6 +248,15 @@ access: {
}
```

`getSessionFromAuth()` (`@opensaas/stack-auth/server`) is the single implementation of this
projection — the scaffolded `getSession()` calls it with `sessionFields` read from the resolved
config at runtime. Each name resolves against a fixed precedence (a top-level key on the
resolved session, then `user`, then `session`), with `userId` special-cased to the user's `id`.
A `customSession` better-auth plugin fully replaces the resolved shape and can nest fields
anywhere; reconciling that against `sessionFields` is the app's job — an unresolvable name is
omitted and warns once (per field, per process) rather than silently becoming `undefined`. See
the `sessionFields` reference (`docs/content/reference/auth.md`) for the full contract.

### Session Type Safety

To get autocomplete and type safety for session fields, use module augmentation:
Expand Down
17 changes: 15 additions & 2 deletions packages/auth/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,21 @@ export type AuthConfig = {
schema?: string

/**
* Which fields to include in the session object
* This determines what data is available in access control functions
* Which fields to include in the session object passed to access control
* functions — a **flattened projection** of the resolved better-auth
* session, not the session's own shape. `getSessionFromAuth` (the
* implementation the scaffolded `getSession()` calls) resolves each name
* against a fixed precedence: a top-level key on the resolved session
* object, then the `user` object, then the `session` sub-object.
* `userId` is special-cased to the authenticated user's `id`.
*
* A `customSession` better-auth plugin fully replaces the resolved shape
* (it can nest fields anywhere, e.g. under its own custom key) —
* reconciling that shape against `sessionFields` is the application's job.
* A listed name that can't be resolved is omitted and warns once per
* field per process, naming what was checked, rather than silently
* becoming `undefined` in an access control function.
*
* @default ['userId', 'email', 'name']
*
* @example
Expand Down
130 changes: 105 additions & 25 deletions packages/auth/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { nextCookies } from 'better-auth/next-js'
import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
import type { DatabaseConfig } from '@opensaas/stack-core/internal'
import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'

Expand Down Expand Up @@ -475,41 +475,121 @@ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
}

/**
* Get session from better-auth and transform it to OpenSaas session format.
* Field names already warned about failing to resolve against a session, so a
* given field warns at most once per process rather than once per request.
*/
const unresolvedSessionFieldWarnings = new Set<string>()

/**
* Warn (once per field, per process) that a `sessionFields` entry could not
* be resolved from the session shape `auth.api.getSession()` actually
* returned — naming the field and what keys were available to check, so the
* gap is visible here instead of surfacing later as an access-control
* function silently reading `undefined`.
*/
function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
if (unresolvedSessionFieldWarnings.has(field)) return
unresolvedSessionFieldWarnings.add(field)

const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined

console.warn(
`[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
`Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
`its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
`and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
`The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
`value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
`This warning will not repeat for "${field}".`,
)
}

/**
* Resolve a single `sessionFields` entry off the resolved better-auth
* session (whatever `auth.api.getSession()` returned — the default `{
* session, user }` shape, or a `customSession` plugin's replaced shape).
*
* `userId` is special-cased to the authenticated user's `id` — the
* documented default apps depend on. Every other name resolves against a
* fixed precedence so a collision between sources is predictable rather
* than incidental: a top-level key on the resolved session object, then the
* `user` object, then the `session` sub-object.
*/
function resolveSessionField(
field: string,
resolvedSession: Record<string, unknown>,
): { found: true; value: unknown } | { found: false } {
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined

if (field === 'userId') {
return user && 'id' in user ? { found: true, value: user.id } : { found: false }
}

if (field in resolvedSession) {
return { found: true, value: resolvedSession[field] }
}
if (user && field in user) {
return { found: true, value: user[field] }
}
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
if (sessionRow && field in sessionRow) {
return { found: true, value: sessionRow[field] }
}
return { found: false }
}

/**
* Get session from better-auth and transform it to OpenSaas session format —
* a flattened projection of `sessionFields` off the *resolved* session
* object, not just its `user` sub-object. This is what makes a
* `customSession` plugin's fields (added at the top level, or a
* session-only field like the admin plugin's `impersonatedBy`) reachable.
* See the `sessionFields` reference for the resolution precedence.
*
* Not called by any generated code today — apps currently hand-roll this same
* transform against `auth.api.getSession({ headers: await headers() })` (see
* `examples/starter-auth/lib/auth.ts`). Exported as a reusable helper for that
* pattern; pass the caller's request headers (e.g. Next.js `await headers()`
* in a Server Component/action) so a session cookie can actually be resolved.
* Returns `null` only when there is genuinely no session — a resolved
* session with no `user` key (a `customSession` plugin that dropped it) is
* still a session and still gets projected, never misreported as anonymous.
* A listed field that can't be resolved from the session shape is omitted
* and warns once per field per process (see `warnUnresolvedSessionField`)
* instead of silently vanishing into an access-control function reading
* `undefined`.
*
* Errors from the underlying `auth.api.getSession()` call propagate rather
* than becoming `null` — collapsing a lookup failure (e.g. a session-store
* outage) into "anonymous" is indistinguishable from a mass sign-out under
* fail-closed access control, so the caller must see it.
*
* Not called by any generated code before this helper existed — apps used to
* hand-roll this same transform against `auth.api.getSession({ headers:
* await headers() })`. Exported as the single reusable implementation; pass
* the caller's request headers (e.g. Next.js `await headers()` in a Server
* Component/action) so a session cookie can actually be resolved.
*/
export async function getSessionFromAuth(
auth: ReturnType<typeof betterAuth>,
sessionFields: string[],
headers: Headers,
) {
try {
const session = await auth.api.getSession({ headers })
): Promise<Session | null> {
const resolvedSession = await auth.api.getSession({ headers })

if (!session?.user) {
return null
}
if (!resolvedSession) {
return null
}

// Build session object with requested fields
const result: Record<string, unknown> = {}
const resolvedSessionRecord = resolvedSession as Record<string, unknown>
const result: Record<string, unknown> = {}

for (const field of sessionFields) {
if (field === 'userId') {
result.userId = session.user.id
} else if (field in session.user) {
result[field] = session.user[field as keyof typeof session.user]
}
for (const field of sessionFields) {
const resolved = resolveSessionField(field, resolvedSessionRecord)
if (resolved.found) {
result[field] = resolved.value
} else {
warnUnresolvedSessionField(field, resolvedSessionRecord)
}

return result
} catch {
return null
}

return result
}

export type { BetterAuthOptions }
Loading
Loading