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

`buildBetterAuthOptions()` and `createAuth()` now accept an optional third argument — your app's `betterAuthPlugins` array, the same array passed to `authPlugin({ betterAuthPlugins })` — so the returned options/`Auth` type carries the literal plugin tuple instead of the widened `BetterAuthOptions`/`Auth<BetterAuthOptions>`. Without this, `betterAuth()` constructed from the widened return loses plugin-derived `auth.api.*` endpoints (e.g. `emailOTP()`'s `signInEmailOTP`) and a `customSession()` plugin's replaced session shape.

```typescript
export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })] // same array passed to authPlugin({ betterAuthPlugins })

export const auth = betterAuth({
...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)),
})
// auth.api.signInEmailOTP is now typed, and auth.api.getSession() returns your customSession() shape.
```

The supplied tuple is for typing only — the plugin array used at runtime is always the one resolved from `authPlugin({ betterAuthPlugins })`. Passing a tuple that isn't the same plugin instances in the same order throws, naming the mismatch, so the two can't silently drift apart. Calling either function with no third argument is unchanged — same widened return type, same runtime options, fully backwards compatible.

Also, `AuthConfig`/`NormalizedAuthConfig`'s `betterAuthPlugins` field is now typed as better-auth's own `BetterAuthPlugin[]` instead of `any[]`.
65 changes: 65 additions & 0 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,71 @@ an explicit, reviewable diff. It also gives an incremental migration path onto
`createAuth()`: adopt the builder first, then move options into
[`betterAuthOptions`](#betterauthoptions) as the stack grows knobs for them.

### Typed `auth.api.*` reads: pass your plugin tuple

Called with just `(config, context)`, both `createAuth()` and
`buildBetterAuthOptions()` return the **widened** `BetterAuthOptions` /
`Auth<BetterAuthOptions>` types. better-auth infers plugin endpoints and a
`customSession()`'s replaced session shape from the _literal_ type of the
options object, so constructing from a widened type erases them — a plugin
like `emailOTP()` loses `auth.api.signInEmailOTP`, and `auth.api.getSession()`
falls back to better-auth's default `{ user, session }` instead of your
`customSession()` callback's return type.

If your app reads `auth.api.*` in typed code and uses either of those,
**pass your `betterAuthPlugins` array as a third argument** — the exact same
array already passed to `authPlugin({ betterAuthPlugins })` — to either
function:

```typescript
// auth-plugins.ts
import { emailOTP } from 'better-auth/plugins'

export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })]
```

```typescript
// opensaas.config.ts
import { authPlugin } from '@opensaas/stack-auth'
import { appBetterAuthPlugins } from './auth-plugins'

export default config({
plugins: [authPlugin({ betterAuthPlugins: appBetterAuthPlugins })],
// ...
})
```

```typescript
// lib/auth.ts
import { betterAuth } from 'better-auth'
import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
import { appBetterAuthPlugins } from '../auth-plugins'

export const auth = betterAuth({
...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)),
databaseHooks: { user: { create: { after: syncDomainUser } } },
})
// auth.api.signInEmailOTP is now typed, and auth.api.getSession() returns
// your customSession() shape if you have one.
```

The same third argument works on `createAuth()` — `createAuth(config, rawOpensaasContext, appBetterAuthPlugins)`.
Either way, the supplied array is for typing only: the plugin array actually
used at runtime is always the one resolved from `authPlugin({ betterAuthPlugins })`,
with exactly one `nextCookies()` appended last. Passing an array that isn't
the same plugin instances in the same order throws, naming the mismatch, so
the two can't silently drift apart.

**Which entry point to reach for:** `createAuth()`'s lazy `Proxy` does not
behave identically to a real `Auth` instance for every property — every
access, including a non-function property, is surfaced through an `async`
wrapper (so `auth.options`, for example, reads back as a `Promise` rather than
the plain object a real instance returns synchronously). If your app reads
`auth.api.*` in typed code, reach for `buildBetterAuthOptions()` plus
`betterAuth()` — it constructs a real instance and does not have this gap.

## Client Setup

Create a client for authentication in your components:
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export default [
...tseslint.configs.recommended.rules,
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// Base `no-redeclare` doesn't understand TypeScript function overload
// signatures and flags each one as a duplicate declaration;
// `@typescript-eslint/no-redeclare` is overload-aware.
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
Expand Down
41 changes: 39 additions & 2 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Auto-generated lists:

### Server (`src/server/index.ts`)

- `createAuth(config, rawContext?)` - Creates Better-auth instance with MCP plugin support
- `buildBetterAuthOptions(config, rawContext?)` - Returns the same `BetterAuthOptions` `createAuth()` builds, without constructing an instance — for apps that need to hand-wire their own `betterAuth()`
- `createAuth(config, rawContext?, betterAuthPlugins?)` - Creates Better-auth instance with MCP plugin support
- `buildBetterAuthOptions(config, rawContext?, betterAuthPlugins?)` - Returns the same `BetterAuthOptions` `createAuth()` builds, without constructing an instance — for apps that need to hand-wire their own `betterAuth()`. The optional third argument (the app's own `betterAuthPlugins` array) makes the return type carry that literal plugin tuple instead of the widened array type — see "Typed `auth.api.*` reads" below.
- Returns `{ handler, signIn, signOut, ... }` - Better-auth methods

### Client (`src/client/index.ts`)
Expand Down Expand Up @@ -391,6 +391,43 @@ can't be synchronous. It gives an incremental path onto `createAuth()`: adopt
the builder first, then fold options into `betterAuthOptions` above as the
stack grows first-class config for them.

**Typed `auth.api.*` reads.** Called with just `(config, context)`, both
`buildBetterAuthOptions()` and `createAuth()` return the widened
`BetterAuthOptions` / `Auth<BetterAuthOptions>` — better-auth infers plugin
endpoints and a `customSession()`'s replaced session shape from the _literal_
options type, so the widened form erases them (an `emailOTP()` plugin loses
`auth.api.signInEmailOTP`; `auth.api.getSession()` falls back to `{ user,
session }` instead of a `customSession()` shape). Both functions take the
app's `betterAuthPlugins` array — the exact same array passed to
`authPlugin({ betterAuthPlugins })` — as an optional third argument, and their
return type then carries that literal tuple (plus the `nextCookies()` the
stack always appends last) instead of the widened array type:

```typescript
export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })] // same array passed to authPlugin({ betterAuthPlugins })

export const auth = betterAuth({
...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)),
})
// auth.api.signInEmailOTP is now typed.
```

The supplied tuple is for typing only — the plugin array used at runtime is
always the one resolved from `authPlugin({ betterAuthPlugins })` — so both
functions verify the supplied tuple is the same plugin instances in the same
order as the resolved array, throwing a prefixed error naming the mismatch if
not (`assertPluginTupleMatchesResolved` in `src/server/index.ts`). This is
what closes the drift hole a hand-rolled re-pass of the plugin array would
otherwise open.

`createAuth()`'s lazy `Proxy` does not behave identically to a real `Auth`
instance for every property regardless of which form you use — every access,
including a non-function property, is surfaced through an `async` wrapper (so
`auth.options` reads back as a `Promise`, not the plain object a real
instance returns synchronously). Reach for `buildBetterAuthOptions()` plus
`betterAuth()` instead when the app reads `auth.api.*` in typed code — it
constructs a real instance and does not have this gap.

## Integration Points

### With @opensaas/stack-core
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function authPlugin(config: AuthConfig): Plugin {
// `oauth_application`, `passkey`) still register as new lists via
// `addList`, same as before.
for (const plugin of normalized.betterAuthPlugins) {
if (plugin && typeof plugin === 'object' && 'schema' in plugin) {
if (plugin && typeof plugin === 'object' && plugin.schema) {
// Plugin has schema property - convert to OpenSaaS lists
const pluginSchema = plugin.schema
const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
Expand Down
8 changes: 3 additions & 5 deletions packages/auth/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ListConfig } from '@opensaas/stack-core'
import type { BetterAuthOptions, User } from 'better-auth'
import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth'
import type { ExtendUserListConfig } from '../lists/index.js'

/**
Expand Down Expand Up @@ -394,8 +394,7 @@ export type AuthConfig = {
* ]
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
betterAuthPlugins?: any[]
betterAuthPlugins?: BetterAuthPlugin[]

/**
* Rate limiting configuration
Expand Down Expand Up @@ -528,8 +527,7 @@ export type NormalizedAuthConfig = Required<
* default (used to wire the datasource `schemas` array during generation).
*/
schema?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
betterAuthPlugins: any[]
betterAuthPlugins: BetterAuthPlugin[]
rateLimit?: {
enabled: boolean
window?: number
Expand Down
59 changes: 59 additions & 0 deletions packages/auth/src/server/build-better-auth-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expectTypeOf } from 'vitest'
import { betterAuth } from 'better-auth'
import type { BetterAuthOptions } from 'better-auth'
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
import { emailOTP, customSession } from 'better-auth/plugins'
import { buildBetterAuthOptions } from './index.js'

// The literal shape a `customSession()` callback replaces the session with —
// deliberately unlike better-auth's default `{ user, session }`, to prove the
// builder's return type carries a custom shape through to `api.getSession()`.
type AppSession = {
data: { allowAdminUI: boolean; subjectId: string }
}

type TestPlugins = [ReturnType<typeof emailOTP>, ReturnType<typeof customSession<AppSession>>]

// `ReturnType<typeof buildBetterAuthOptions>` on the overloaded export itself
// resolves against its last (generic) signature, not the call-site-selected
// one — wrapping each call shape in its own ordinary function and reading
// `ReturnType<typeof wrapper>` instead forces TS to resolve overloads exactly
// as a real call site would.
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
function callWithNoPlugins(
config: OpenSaasConfig | Promise<OpenSaasConfig>,
context: AccessContext | Promise<AccessContext>,
) {
return buildBetterAuthOptions(config, context)
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
function callWithPlugins(
config: OpenSaasConfig | Promise<OpenSaasConfig>,
context: AccessContext | Promise<AccessContext>,
plugins: TestPlugins,
) {
return buildBetterAuthOptions(config, context, plugins)
}

type NoArgResult = Awaited<ReturnType<typeof callWithNoPlugins>>
type BuiltOptionsWithPlugins = Awaited<ReturnType<typeof callWithPlugins>>
type ConstructedAuth = ReturnType<typeof betterAuth<BuiltOptionsWithPlugins>>

describe('buildBetterAuthOptions plugin-tuple typing', () => {
it('back-compat: the no-argument call still returns the widened BetterAuthOptions', () => {
expectTypeOf<NoArgResult>().toEqualTypeOf<BetterAuthOptions>()
})

it('preserves plugin-derived auth.api.* endpoints and a customSession shape', () => {
// emailOTP() endpoints exist on the constructed Auth's `api` — erased entirely
// when constructed from the widened `BetterAuthOptions` (see #876).
expectTypeOf<ConstructedAuth['api']['signInEmailOTP']>().not.toBeNever()
expectTypeOf<ConstructedAuth['api']['sendVerificationOTP']>().not.toBeNever()
expectTypeOf<ConstructedAuth['api']['checkVerificationOTP']>().not.toBeNever()

// customSession()'s replaced shape, not better-auth's default { user, session }.
type GetSessionReturn = Awaited<ReturnType<ConstructedAuth['api']['getSession']>>
expectTypeOf<GetSessionReturn>().toEqualTypeOf<AppSession | null>()
})
})
Loading
Loading