diff --git a/.changeset/warm-sessions-project.md b/.changeset/warm-sessions-project.md new file mode 100644 index 00000000..633ba4a1 --- /dev/null +++ b/.changeset/warm-sessions-project.md @@ -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()) +} +``` diff --git a/docs/content/reference/auth.md b/docs/content/reference/auth.md index 7444ebfc..701fa1a8 100644 --- a/docs/content/reference/auth.md +++ b/docs/content/reference/auth.md @@ -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: diff --git a/examples/auth-demo/lib/auth.ts b/examples/auth-demo/lib/auth.ts index 94a79a09..eae01048 100644 --- a/examples/auth-demo/lib/auth.ts +++ b/examples/auth-demo/lib/auth.ts @@ -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' @@ -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 { + 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()) } /** diff --git a/examples/starter-auth/lib/auth.ts b/examples/starter-auth/lib/auth.ts index 2d61bb39..0a52a188 100644 --- a/examples/starter-auth/lib/auth.ts +++ b/examples/starter-auth/lib/auth.ts @@ -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' @@ -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 { + 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()) } /** diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index 8671ee4c..7f332bb7 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -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'] }) @@ -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: diff --git a/packages/auth/src/config/types.ts b/packages/auth/src/config/types.ts index c8602c20..82c40e9d 100644 --- a/packages/auth/src/config/types.ts +++ b/packages/auth/src/config/types.ts @@ -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 diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index 0c0770f5..77265355 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -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' @@ -475,41 +475,121 @@ export function createAuth( } /** - * 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() + +/** + * 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): 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, +): { 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, sessionFields: string[], headers: Headers, -) { - try { - const session = await auth.api.getSession({ headers }) +): Promise { + 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 = {} + const resolvedSessionRecord = resolvedSession as Record + const result: Record = {} - 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 } diff --git a/packages/auth/tests/server.test.ts b/packages/auth/tests/server.test.ts index 11bcb9b1..e818b52b 100644 --- a/packages/auth/tests/server.test.ts +++ b/packages/auth/tests/server.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { BetterAuthOptions } from 'better-auth' import type { NormalizedAuthConfig } from '../src/config/types.js' import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core' @@ -606,14 +606,118 @@ describe('getSessionFromAuth', () => { expect(result).toBeNull() }) - it('returns null when auth.api.getSession throws', async () => { + it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => { const getSession = vi.fn(async () => { throw new Error('boom') }) const auth = { api: { getSession } } as unknown as Parameters[0] - const result = await getSessionFromAuth(auth, ['userId'], new Headers()) + await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom') + }) - expect(result).toBeNull() + it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => { + const getSession = vi.fn(async () => ({ + user: { id: 'user-1', email: 'a@b.com', name: 'Ada' }, + })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers()) + + expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' }) + }) + + it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => { + // A customSession plugin can fully replace the resolved shape (e.g. + // nesting fields under a custom key) and drop the `user` object entirely + // — that must still be treated as "a session", not "no session". + const getSession = vi.fn(async () => ({ + email: 'nested@example.com', + data: { role: 'admin' }, + })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await getSessionFromAuth(auth, ['email'], new Headers()) + + expect(result).not.toBeNull() + expect(result).toEqual({ email: 'nested@example.com' }) + }) + + it('resolves a field living on the session sub-object, not just the user', async () => { + const getSession = vi.fn(async () => ({ + user: { id: 'user-1' }, + session: { impersonatedBy: 'admin-1' }, + })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers()) + + expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' }) + }) + + describe('resolution precedence', () => { + it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => { + const getSession = vi.fn(async () => ({ + role: 'top-level-role', + user: { role: 'user-role' }, + session: { role: 'session-role' }, + })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await getSessionFromAuth(auth, ['role'], new Headers()) + + expect(result).toEqual({ role: 'top-level-role' }) + }) + + it('prefers the user object over the session sub-object when there is no top-level key', async () => { + const getSession = vi.fn(async () => ({ + user: { role: 'user-role' }, + session: { role: 'session-role' }, + })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await getSessionFromAuth(auth, ['role'], new Headers()) + + expect(result).toEqual({ role: 'user-role' }) + }) + }) + + // The warn-once cache is module-level state, so these tests re-import the + // module fresh via vi.resetModules() — same pattern as the `select` no-op + // warning tests in packages/core/tests/context.test.ts. + describe('unresolved field warning', () => { + let warnSpy: ReturnType + let freshGetSessionFromAuth: typeof getSessionFromAuth + + beforeEach(async () => { + vi.resetModules() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mod = await import('../src/server/index.js') + freshGetSessionFromAuth = mod.getSessionFromAuth + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('omits an unresolvable field, warns once naming it, and does not throw', async () => { + const getSession = vi.fn(async () => ({ user: { id: 'user-1' } })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers()) + + expect(result).toEqual({ userId: 'user-1' }) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0][0]).toContain('"nickname"') + }) + + it('does not warn again for the same field on a second call', async () => { + const getSession = vi.fn(async () => ({ user: { id: 'user-1' } })) + const auth = { api: { getSession } } as unknown as Parameters[0] + + await freshGetSessionFromAuth(auth, ['nickname'], new Headers()) + await freshGetSessionFromAuth(auth, ['nickname'], new Headers()) + + expect(warnSpy).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/packages/cli/src/mcp/lib/generators/feature-generator.ts b/packages/cli/src/mcp/lib/generators/feature-generator.ts index b8343250..c04bde79 100644 --- a/packages/cli/src/mcp/lib/generators/feature-generator.ts +++ b/packages/cli/src/mcp/lib/generators/feature-generator.ts @@ -172,7 +172,9 @@ export default config({ path: 'lib/auth.ts', language: 'typescript', description: 'Better-auth server instance and session helper', - content: `import { createAuth } from '@opensaas/stack-auth/server' + content: `import { createAuth, getSessionFromAuth } from '@opensaas/stack-auth/server' +import type { NormalizedAuthConfig } from '@opensaas/stack-auth' +import type { Session } from '@opensaas/stack-core' import { headers } from 'next/headers' import config from '../opensaas.config' import { rawOpensaasContext } from '@/.opensaas/context' @@ -180,16 +182,15 @@ import { rawOpensaasContext } from '@/.opensaas/context' export const auth = createAuth(config, rawOpensaasContext) /** - * Get the current session in OpenSaas format (the configured sessionFields). + * 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,${hasRoles ? `\n role: (session.user as { role?: string }).role,` : ''} - } +export async function getSession(): Promise { + 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()) } export const GET = auth.handler