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

Auth forms now submit through app-owned server actions instead of the browser `authClient`

The pre-built auth forms (`SignInForm`, `SignUpForm`, `ForgotPasswordForm`, and the new
`ResetPasswordForm`) no longer take an `authClient` prop that calls `/api/auth/*` from the
browser. Instead each form takes **server action** props — `'use server'` functions the app
defines against its own `auth` instance. This keeps the auth network surface server-side and
matches the app's existing `lib/actions/*` convention. `createAuth` now auto-adds
better-auth's `nextCookies` plugin, so the session cookie set inside a server action persists.
See ADR-0020.

The package exports the action contract types (`AuthActionResult`, `SignInInput`,
`SignUpInput`, `RequestPasswordResetInput`, `ResetPasswordInput`, and the action aliases).
`createClient` is unchanged for client-side session reading (`useSession`).

Migration — define the actions in your app and pass them to the forms:

```typescript
// lib/actions/auth.ts
'use server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui'

export async function signInAction(input: SignInInput): Promise<AuthActionResult> {
try {
await auth.api.signInEmail({
body: { email: input.email, password: input.password },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Sign in failed' }
}
}
```

```tsx
// Before
<SignInForm authClient={authClient} redirectTo="/admin" />

// After
<SignInForm signInAction={signInAction} redirectTo="/admin" />
```

Social sign-in becomes a redirecting server action passed as `signInSocialAction`. The CLI
feature-generator now scaffolds `lib/actions/auth.ts` and a `reset-password` page, and no
longer emits `lib/auth-client.ts`.
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ _Avoid_: auth tables, auth models, auth schema
The better-auth-owned record of who a session belongs to (the better-auth user). Separate from, and not assumed to be, the application's own domain User; an app links the two itself when it needs to.
_Avoid_: auth user, principal, account

**Auth action**:
An app-owned `'use server'` function that runs an authentication mutation (sign in, sign up, request/perform password reset, social sign-in) by calling better-auth's server API directly against the app's own auth instance. The pre-built auth forms invoke Auth actions passed as props — one prop per concern — instead of the browser calling the `/api/auth/*` endpoints; the auth instance therefore never leaves the server, and the package owns only the form components and the actions' contract types, never the actions themselves.
_Avoid_: auth handler, form action, authClient call

### Storage

**Storage provider**:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Auth forms submit through app-owned server actions, not the browser auth client

## Context

The pre-built auth forms (`SignInForm`, `SignUpForm`, `ForgotPasswordForm`, and now
`ResetPasswordForm`) took a better-auth `authClient` prop and called `authClient.*`
from the browser, which hit the `/api/auth/*` REST route directly. We want the
authentication network surface to stay server-side (no browser dependency on
`NEXT_PUBLIC_APP_URL`, fewer origin/CORS concerns) and to match the app's existing
`lib/actions/*` server-action convention.

## Decision

The forms now invoke **Auth actions** — app-owned `'use server'` functions that call
better-auth's server API (`auth.api.*`) against the app's own auth instance — passed
in as props, **one prop per concern** (`signInAction`, `signUpAction`,
`requestPasswordResetAction`, `resetPasswordAction`, `signInSocialAction`). The
`authClient` prop is removed from every form.

- **The package owns only the form components and the actions' contract types**
(`SignInInput`, `SignUpInput`, `RequestPasswordResetInput`, `ResetPasswordInput`,
and `AuthActionResult = { success: true } | { success: false; error: string }`).
It does **not** export shared action logic — better-auth's `auth.api.*` already is
that logic, and threading the app's auth instance through a package helper buys
friction, not safety. The `[body.field]` error-message cleanup stays internal to
the form.
- **The app (and the `starter-auth` template / CLI generator) owns the action
implementations** in `lib/actions/auth.ts`, calling `auth.api.*` directly.
- **`createAuth` auto-adds better-auth's `nextCookies()` plugin as the last plugin**,
so a session cookie set during a server-action call is written into Next's
`cookies()` without every app remembering the one step that makes sign-in persist.
- **Redirect is asymmetric by design:** email actions return an `AuthActionResult`
and the client form redirects (`redirectTo` + `onSuccess`/`onError` preserved),
while social sign-in must navigate away, so its action performs a server-side
`redirect()` to the provider URL. This reflects that OAuth leaves the app and email
does not — not an inconsistency.
- **`createClient` is kept** in the package for client-side session reading
(`useSession`), but the now-unused `auth-client.ts` is dropped from the templates.

## Considered options

- **Keep `authClient` for social only** — rejected: a form would need both an action
prop and `authClient`, reintroducing exactly what we removed.
- **Server-side `redirect()` for email too** — rejected: drops the client-controlled
`redirectTo` and the `onSuccess`/`onError` callbacks, and errors still have to come
back as data anyway.
- **A package `createAuthActions(auth)` factory / exported action helpers** —
rejected: shadows better-auth's own API and couples the package to a fragile
`ReturnType<typeof betterAuth>` parameter.

## Consequences

- Breaking change to `@opensaas/stack-auth/ui`'s form props (`authClient` → per-concern
action props). Released as a **minor** bump: the packages are pre-1.0 (0.x), where a
minor may carry breaking changes by semver convention. The CLI feature-generator and
all three auth examples (`starter-auth`, `auth-demo`, `mcp-demo`) are updated in the
same change, since a green build and a correct scaffolder require it.
- This ADR also folds in the previously-missing `reset-password` page and
`ResetPasswordForm`, completing the forgot-password flow that until now dead-ended
on a 404.
4 changes: 2 additions & 2 deletions examples/auth-demo/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
import { requestPasswordResetAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default function ForgotPasswordPage() {
Expand All @@ -12,7 +12,7 @@ export default function ForgotPasswordPage() {
</div>

<div className="bg-white rounded-lg shadow-md p-8">
<ForgotPasswordForm authClient={authClient} />
<ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />

<div className="mt-6 text-center text-sm">
<Link href="/sign-in" className="text-blue-600 hover:underline">
Expand Down
32 changes: 32 additions & 0 deletions examples/auth-demo/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ResetPasswordForm } from '@opensaas/stack-auth/ui'
import { resetPasswordAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>
}) {
const { token } = await searchParams

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold">Set a New Password</h1>
<p className="text-gray-600 mt-2">Choose a new password for your account</p>
</div>

<div className="bg-white rounded-lg shadow-md p-8">
<ResetPasswordForm resetPasswordAction={resetPasswordAction} token={token ?? ''} />

<div className="mt-6 text-center text-sm">
<Link href="/sign-in" className="text-blue-600 hover:underline">
Back to sign in
</Link>
</div>
</div>
</div>
</div>
)
}
4 changes: 2 additions & 2 deletions examples/auth-demo/app/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
import { signInAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default function SignInPage() {
Expand All @@ -12,7 +12,7 @@ export default function SignInPage() {
</div>

<div className="bg-gray-500 rounded-lg shadow-md p-8">
<SignInForm authClient={authClient} redirectTo="/admin" showSocialProviders={false} />
<SignInForm signInAction={signInAction} redirectTo="/admin" showSocialProviders={false} />

<div className="mt-6 text-center text-sm">
<Link href="/forgot-password" className="text-blue-600 hover:underline">
Expand Down
4 changes: 2 additions & 2 deletions examples/auth-demo/app/sign-up/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SignUpForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
import { signUpAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default function SignUpPage() {
Expand All @@ -13,7 +13,7 @@ export default function SignUpPage() {

<div className="bg-gray-600 rounded-lg shadow-md p-8">
<SignUpForm
authClient={authClient}
signUpAction={signUpAction}
redirectTo="/admin"
showSocialProviders={false}
requirePasswordConfirmation={true}
Expand Down
72 changes: 72 additions & 0 deletions examples/auth-demo/lib/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use server'

import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import type {
AuthActionResult,
SignInInput,
SignUpInput,
RequestPasswordResetInput,
ResetPasswordInput,
} from '@opensaas/stack-auth/ui'

/**
* App-owned auth server actions. The pre-built auth forms submit through these
* (calling better-auth's server API directly) instead of the browser calling
* `/api/auth/*`. `createAuth` auto-adds better-auth's `nextCookies` plugin, so
* the session cookie set inside these actions persists. See ADR-0020.
*/

function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error && err.message ? err.message : fallback
}

export async function signInAction(input: SignInInput): Promise<AuthActionResult> {
try {
await auth.api.signInEmail({
body: { email: input.email, password: input.password },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: errorMessage(err, 'Sign in failed') }
}
}

export async function signUpAction(input: SignUpInput): Promise<AuthActionResult> {
try {
await auth.api.signUpEmail({
body: { name: input.name, email: input.email, password: input.password },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: errorMessage(err, 'Sign up failed') }
}
}

export async function requestPasswordResetAction(
input: RequestPasswordResetInput,
): Promise<AuthActionResult> {
try {
await auth.api.requestPasswordReset({
body: { email: input.email, redirectTo: '/reset-password' },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: errorMessage(err, 'Failed to send reset email') }
}
}

export async function resetPasswordAction(input: ResetPasswordInput): Promise<AuthActionResult> {
try {
await auth.api.resetPassword({
body: { newPassword: input.password, token: input.token },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: errorMessage(err, 'Failed to reset password') }
}
}
21 changes: 0 additions & 21 deletions examples/auth-demo/lib/auth-client.ts

This file was deleted.

4 changes: 2 additions & 2 deletions examples/mcp-demo/app/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
import { signInAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default function SignInPage() {
Expand All @@ -12,7 +12,7 @@ export default function SignInPage() {
</div>

<div className="bg-white rounded-lg shadow-md p-8">
<SignInForm authClient={authClient} redirectTo="/admin" showSocialProviders={false} />
<SignInForm signInAction={signInAction} redirectTo="/admin" showSocialProviders={false} />

<div className="mt-6 text-center text-sm">
<Link href="/forgot-password" className="text-blue-600 hover:underline">
Expand Down
28 changes: 28 additions & 0 deletions examples/mcp-demo/lib/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use server'

import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui'

/**
* App-owned auth server actions. The pre-built auth forms submit through these
* (calling better-auth's server API directly) instead of the browser calling
* `/api/auth/*`. `createAuth` auto-adds better-auth's `nextCookies` plugin, so
* the session cookie set inside these actions persists. See ADR-0020.
*/

function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error && err.message ? err.message : fallback
}

export async function signInAction(input: SignInInput): Promise<AuthActionResult> {
try {
await auth.api.signInEmail({
body: { email: input.email, password: input.password },
headers: await headers(),
})
return { success: true }
} catch (err) {
return { success: false, error: errorMessage(err, 'Sign in failed') }
}
}
21 changes: 0 additions & 21 deletions examples/mcp-demo/lib/auth-client.ts

This file was deleted.

4 changes: 2 additions & 2 deletions examples/starter-auth/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
import { requestPasswordResetAction } from '@/lib/actions/auth'
import Link from 'next/link'

export default function ForgotPasswordPage() {
Expand All @@ -12,7 +12,7 @@ export default function ForgotPasswordPage() {
</div>

<div className="bg-card text-card-foreground border border-border rounded-lg shadow-md p-8">
<ForgotPasswordForm authClient={authClient} />
<ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />

<div className="mt-6 text-center text-sm">
<Link href="/sign-in" className="text-primary hover:underline">
Expand Down
Loading
Loading