Authentication Guide
This guide covers everything you need to implement authentication in your OpenSaaS Stack application using Better-auth integration.
Introduction
OpenSaaS Stack provides seamless authentication through the @opensaas/stack-auth package, which integrates Better-auth with the stack's access control system. You get:
- Email/password authentication out of the box
- OAuth/social login (GitHub, Google, Discord, Twitter)
- Email verification and password reset flows
- Session management with secure HTTP-only cookies
- Pre-built UI components for common auth flows
- Automatic session injection into access control functions
- Type-safe sessions with configurable fields
Authentication setup takes less than 5 minutes, and the session is automatically available throughout your application.
Quick Start
Here's the minimal setup to add authentication to your app:
// opensaas.config.ts
import { config } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
db: { provider: 'sqlite', url: 'file:./dev.db' },
lists: {
/* your lists */
},
})
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)
export const GET = auth.handler
export const POST = auth.handler
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
That's it! You now have authentication endpoints and auto-generated User, Session, Account, and Verification lists.
Installation & Setup
1. Install the Package
pnpm add @opensaas/stack-auth
2. Set Environment Variables
Create a .env file with the following:
# Database
DATABASE_URL=file:./dev.db
# Better Auth
BETTER_AUTH_SECRET=your_secret_key_here # Generate with: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
# Public URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# OAuth (optional)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
3. Configure the Auth Plugin
Add the auth plugin to your OpenSaaS config:
// opensaas.config.ts
import { config, list, text, relationship } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
// Email and password authentication
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
requireConfirmation: true,
},
// Password reset functionality
passwordReset: {
enabled: true,
tokenExpiration: 3600, // 1 hour
},
// Fields available in session object
sessionFields: ['userId', 'email', 'name'],
// Extend User list with custom fields
extendUserList: {
fields: {
posts: relationship({ ref: 'Post.author', many: true }),
},
},
}),
],
db: {
provider: 'sqlite',
url: process.env.DATABASE_URL || 'file:./dev.db',
},
lists: {
Post: list({
fields: {
title: text(),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
create: ({ session }) => !!session,
},
},
}),
},
})
4. Generate Database Schema
pnpm generate
pnpm db:push
This creates the auth tables (User, Session, Account, Verification) in your database.
Creating Authentication Pages
Server Setup
Create a server-side auth instance:
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
// Create auth instance
export const auth = createAuth(config, rawOpensaasContext)
// Helper to get current session
export async function getAuth() {
const session = await auth.api.getSession({
headers: await headers(),
})
return session
}
// Export handlers for API routes
export const GET = auth.handler
export const POST = auth.handler
Client Setup
Create a client-side auth instance:
// lib/auth-client.ts
'use client'
import { createClient } from '@opensaas/stack-auth/client'
export const authClient = createClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
// Optional: Export individual methods for convenience
export const { signIn, signUp, signOut, useSession } = authClient
API Routes
Create the catch-all auth route:
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
This handles all auth endpoints:
/api/auth/sign-in/api/auth/sign-up/api/auth/sign-out/api/auth/session/api/auth/forgot-password/api/auth/reset-password
Sign-In Page
Create a sign-in page using the pre-built component:
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function SignInPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign In</h1>
<SignInForm
authClient={authClient}
redirectTo="/admin"
showSocialProviders={false}
/>
</div>
)
}
Sign-Up Page
Create a sign-up page:
// app/sign-up/page.tsx
import { SignUpForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function SignUpPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign Up</h1>
<SignUpForm
authClient={authClient}
redirectTo="/admin"
requirePasswordConfirmation={true}
/>
</div>
)
}
Forgot Password Page
Create a password reset request page:
// app/forgot-password/page.tsx
import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function ForgotPasswordPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Reset Password</h1>
<ForgotPasswordForm authClient={authClient} />
</div>
)
}
Protected Routes
OpenSaaS Stack doesn't use Next.js middleware for authentication. Instead, protect routes at the page/component level by checking the session.
Protecting Admin Pages
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import { getContext, config } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage({ params, searchParams }) {
const session = await getAuth()
// Redirect unauthenticated users
if (!session) {
redirect('/sign-in')
}
// Or show an error message
if (!session) {
return (
<div className="p-8">
<div className="bg-destructive/10 border border-destructive rounded-lg p-6">
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-muted-foreground">
You must be logged in to access the admin interface.
</p>
<a href="/sign-in" className="text-primary underline mt-4 inline-block">
Sign In
</a>
</div>
</div>
)
}
return (
<AdminUI
context={await getContext(session.user)}
config={await config}
params={params.admin}
searchParams={searchParams}
/>
)
}
Protecting Server Actions
// lib/actions/posts.ts
'use server'
import { getContext } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
export async function createPost(data: PostCreateInput) {
const session = await getAuth()
if (!session) {
return { success: false, error: 'Not authenticated' }
}
const context = await getContext(session.user)
const post = await context.db.post.create({
data: {
...data,
author: { connect: { id: session.user.id } },
},
})
if (!post) {
return { success: false, error: 'Access denied' }
}
return { success: true, post }
}
Client-Side Protection
'use client'
import { useSession } from '@/lib/auth-client'
import { redirect } from 'next/navigation'
export function ProtectedComponent() {
const { data: session, isPending } = useSession()
if (isPending) {
return <div>Loading...</div>
}
if (!session) {
redirect('/sign-in')
}
return <div>Protected content for {session.user.name}</div>
}
Session Management
Server-Side Session Access
Get the current session in server components or actions:
import { getAuth } from '@/lib/auth'
export default async function MyPage() {
const session = await getAuth()
if (!session) {
return <div>Not signed in</div>
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>Email: {session.user.email}</p>
</div>
)
}
The session object contains:
{
user: {
id: string
email: string
name: string | null
image: string | null
emailVerified: boolean
// ... any custom fields from sessionFields
}
session: {
token: string
expiresAt: Date
ipAddress: string | null
userAgent: string | null
}
}
Client-Side Session Hook
Use the useSession() hook in client components:
'use client'
import { authClient } from '@/lib/auth-client'
export function UserProfile() {
const { data: session, isPending, error } = authClient.useSession()
if (isPending) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
if (!session) {
return <div>Not signed in</div>
}
return (
<div>
<img src={session.user.image || '/default-avatar.png'} alt="Avatar" />
<h2>{session.user.name}</h2>
<p>{session.user.email}</p>
</div>
)
}
Session Configuration
Configure which fields are available in the session:
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role', 'company'],
})
These fields will be:
- Available in the session object
- Automatically typed in TypeScript
- Passed to all access control functions
Access Control Integration
The session is automatically injected into all access control functions. This makes it easy to implement user-based permissions.
Operation-Level Access Control
Control who can perform operations on a list:
Post: list({
fields: {
title: text(),
content: text(),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
// Anyone can view published posts
query: () => true,
// Only authenticated users can create posts
create: ({ session }) => !!session,
// Only the author can update their posts
update: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
// Only the author can delete their posts
delete: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
},
},
})
Filter-Based Access Control
Filter which records users can access:
Post: list({
access: {
operation: {
query: () => true, // Allow the query operation
},
filter: {
query: ({ session }) => {
// Anonymous users: only published posts
if (!session) {
return { status: { equals: 'published' } }
}
// Authenticated users: published posts + their own drafts
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
},
},
},
})
Field-Level Access Control
Control access to individual fields:
Post: list({
fields: {
title: text(),
content: text(),
// Only the author can read/write internal notes
internalNotes: text({
access: {
read: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
create: ({ session }) => !!session,
update: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
},
}),
},
})
Access Control Helpers
Create reusable access control functions:
// opensaas.config.ts
import type { AccessControl } from '@opensaas/stack-core'
// Check if user is signed in
const isSignedIn: AccessControl = ({ session }) => {
return !!session
}
// Check if user is the author
const isAuthor: AccessControl = ({ session, item }) => {
if (!session) return false
return { authorId: { equals: session.userId } }
}
// Check if user is an admin
const isAdmin: AccessControl = ({ session }) => {
return session?.role === 'admin'
}
export default config({
lists: {
Post: list({
access: {
operation: {
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
}),
User: list({
access: {
operation: {
delete: isAdmin,
},
},
}),
},
})
User List Customization
The auth plugin auto-generates a User list, but you can extend it with custom fields.
Adding Custom Fields
authPlugin({
extendUserList: {
fields: {
// Add a role field
role: select({
options: [
{ label: 'User', value: 'user' },
{ label: 'Admin', value: 'admin' },
{ label: 'Moderator', value: 'moderator' },
],
defaultValue: 'user',
}),
// Add company and phone
company: text(),
phoneNumber: text(),
// Add relationships
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
},
})
Including Custom Fields in Session
Make custom fields available in the session:
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role', 'company'],
extendUserList: {
fields: {
role: select({
/* ... */
}),
company: text(),
},
},
})
Now you can access these in access control:
access: {
operation: {
delete: ({ session }) => {
// session.role is now typed and available
return session?.role === 'admin'
},
},
}
Custom Access Control on User List
Override the default User list access control:
authPlugin({
extendUserList: {
access: {
operation: {
// Only admins can view all users
query: ({ session }) => {
if (!session) return false
if (session.role === 'admin') return true
return { id: { equals: session.userId } }
},
// Only admins can delete users
delete: ({ session }) => session?.role === 'admin',
},
},
},
})
Custom Hooks on User List
Add lifecycle hooks to the User list:
authPlugin({
extendUserList: {
hooks: {
afterOperation: async ({ operation, item, context }) => {
if (operation === 'create') {
console.log('New user created:', item.email)
// Send welcome email
await sendWelcomeEmail(item.email)
}
},
},
},
})
OAuth/Social Login
Add OAuth providers for social login.
Configure OAuth Providers
authPlugin({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
enabled: true,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
enabled: true,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
twitter: {
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
},
},
})
Supported providers: github, google, discord, twitter
Setting Up OAuth Apps
GitHub
- Go to GitHub Developer Settings
- Create a new OAuth App
- Set Authorization callback URL to:
http://localhost:3000/api/auth/callback/github - Copy Client ID and Client Secret to
.env
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Create OAuth 2.0 credentials
- Set Authorized redirect URI to:
http://localhost:3000/api/auth/callback/google - Copy Client ID and Client Secret to
.env
Using Social Login in UI
Enable social providers in your sign-in form:
<SignInForm
authClient={authClient}
showSocialProviders={true}
socialProviders={['github', 'google']}
/>
The form will automatically render OAuth buttons for the specified providers.
Custom OAuth Button Styling
You can customize the OAuth buttons using CSS:
.auth-provider-button {
/* Your custom styles */
}
.auth-provider-button[data-provider='github'] {
/* GitHub-specific styles */
}
Email Verification & Password Reset
Configure email flows for verification and password reset.
Email Configuration
authPlugin({
emailVerification: {
enabled: true,
sendOnSignUp: true, // Send verification email on sign-up
tokenExpiration: 86400, // 24 hours
},
passwordReset: {
enabled: true,
tokenExpiration: 3600, // 1 hour
},
// Custom email sending function
sendEmail: async ({ to, subject, html }) => {
// Use your email service
await resend.emails.send({
from: 'noreply@yourapp.com',
to,
subject,
html,
})
},
})
Using Resend
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
authPlugin({
sendEmail: async ({ to, subject, html }) => {
await resend.emails.send({
from: 'onboarding@resend.dev',
to,
subject,
html,
})
},
})
Using SendGrid
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(process.env.SENDGRID_API_KEY!)
authPlugin({
sendEmail: async ({ to, subject, html }) => {
await sgMail.send({
from: 'noreply@yourapp.com',
to,
subject,
html,
})
},
})
Development Mode
If no sendEmail function is provided, emails are logged to the console in development:
📧 Email would be sent to: user@example.com
Subject: Verify your email
Token: abc123def456
Verification link: http://localhost:3000/api/auth/verify-email?token=abc123def456
Email Verification Flow
- User signs up
- Verification email sent with token
- User clicks link:
/api/auth/verify-email?token=... - Better-auth verifies token and marks email as verified
- User can now sign in
Password Reset Flow
- User requests password reset
- Reset email sent with token
- User clicks link and enters new password
- Better-auth verifies token and updates password
- User can sign in with new password
Adopting an Existing better-auth Installation
If you are migrating a project that already runs better-auth — its tables exist, hold live data, and you don't want a destructive auth migration — the plugin can adopt those tables instead of recreating them. This is the common case when migrating an established app to OpenSaaS Stack.
App User vs the Auth identity
A migrating app almost always has two distinct concepts, and conflating them is the main pitfall:
- The application's domain
User— your own model (public.User), keyed however your app likes (e.g. asubjectId), carrying your domain fields (profile, billing, roles, relationships to your other lists). - The Auth identity — the better-auth-owned user record (
AuthUser), the thing a session belongs to. It owns sessions, OAuth accounts, and credentials.
The auth plugin models the Auth identity (the User/Session/Account/ Verification lists better-auth needs). It does not assume its user list is your app's User. As long as the plugin's user model key differs from your app list's key (e.g. the plugin uses AuthUser while your app keeps User), your domain User is left completely untouched — never extended, never overwritten.
Keep these separate. The plugin owns the Auth identity; your app owns its domain User. Linking the two is your application's concern — see Linking your app User to the Auth identity below.
The adoptBetterAuthTables() recipe
Rather than hand-write the four modelNames and a schema on every model, use the adoptBetterAuthTables() recipe. It returns an auth-config fragment with the adoption knobs already set to the conventions of a standard separate-schema better-auth install, and you spread it into authPlugin alongside the rest of your config:
import { config } from '@opensaas/stack-core'
import { authPlugin, adoptBetterAuthTables } from '@opensaas/stack-auth'
export default config({
db: { provider: 'postgresql', url: process.env.DATABASE_URL },
plugins: [
authPlugin({
// Adoption defaults: AuthUser/AuthSession/AuthAccount/AuthVerification
// in the `auth` Postgres schema — matching a live better-auth install.
...adoptBetterAuthTables(),
// The rest of your auth config composes as normal:
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
lists: {
// Your own domain User stays in `public` and is NOT touched by the plugin.
User: list({
fields: {
subjectId: text({ validation: { isRequired: true } }),
// ...your domain fields
},
}),
},
})
With these defaults the plugin derives AuthUser/AuthSession/AuthAccount/ AuthVerification, pins each to its live table name (@@map) and the auth schema (@@schema), and wires Postgres multi-schema automatically. The generated Auth lists therefore reach Schema parity — they diff clean against your live tables, so adding the plugin produces no destructive auth migration. The lists are modelled for runtime and types, not migrated.
Verify with a schema diff after generating:
pnpm generate
# Diff the generated schema against your live database — the auth tables should
# report no changes (Schema parity). See your project's prisma migrate diff setup.
Customising the recipe
The recipe takes options when your live tables diverge from the defaults:
adoptBetterAuthTables({
// Postgres schema the live tables live in (default: 'auth')
schema: 'identity',
// Prefix applied to each model name (default: 'Auth' → AuthUser/AuthSession/…)
modelNamePrefix: 'BA', // → BAUser/BASession/BAAccount/BAVerification
// Column renames, only where your live columns differ from better-auth defaults
fields: {
user: { name: 'full_name', emailVerified: 'is_verified' },
session: { userId: 'user_id' },
},
})
The recipe is just a convenience over the plugin's own schema / per-model modelName / fields options — anything it sets you can also set directly on authPlugin, or override after spreading it in.
Linking your app User to the Auth identity
Because the Auth identity (AuthUser) and your domain User are separate models, your application declares the link — the plugin never imposes a single-User-model assumption. Add a relationship from your domain User to the Auth identity:
lists: {
User: list({
fields: {
subjectId: text({ validation: { isRequired: true } }),
// The app owns the link to the Auth identity:
authIdentity: relationship({ ref: 'AuthUser' }),
},
}),
}
Then resolve from a session's userId (the Auth identity's id) to your domain User in your own code — e.g. look up the domain User whose authIdentity points at session.userId. How you key and resolve that link is entirely up to your app.
Auto-Generated Lists
The auth plugin automatically creates these lists in your database:
User List
{
id: string // Auto-generated UUID
email: string // Unique, required
emailVerified: boolean // Email verification status
name: string | null // Display name
image: string | null // Avatar URL
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
sessions: Session[] // User's sessions
accounts: Account[] // OAuth accounts
// Custom fields from extendUserList
...customFields
}
Access Control:
- Query: Anyone
- Create: Anyone (sign-up)
- Update: Own user only
- Delete: Own user only
Session List
{
id: string // Auto-generated UUID
userId: string // Foreign key to User
token: string // Session token (unique)
expiresAt: DateTime // Session expiration
ipAddress: string | null // Client IP
userAgent: string | null // Client user agent
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
user: User // Session owner
}
Account List
Stores OAuth provider information and password hashes:
{
id: string // Auto-generated UUID
userId: string // Foreign key to User
accountId: string // Provider-specific user ID
providerId: string // 'github', 'google', 'email-password'
accessToken: string | null // OAuth access token
refreshToken: string | null // OAuth refresh token
expiresAt: DateTime | null // Token expiration
password: string | null // Hashed password (for email/password)
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
user: User // Account owner
}
Verification List
Stores email verification and password reset tokens:
{
id: string // Auto-generated UUID
identifier: string // Email address
value: string // Token value
expiresAt: DateTime // Token expiration
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
}
Best Practices
Security
Use Strong Secrets
bash# Generate with: openssl rand -base64 32Silent Failures Access-denied operations return
nullor[]instead of throwing errors. This prevents information leakage about whether records exist:typescriptconst post = await context.db.post.update({ where: { id }, data }) if (!post) { // Could mean: doesn't exist OR access denied return { error: 'Access denied' } }Never Expose Sensitive Fields
typescriptfields: { password: password(), // Automatically excluded from reads apiKey: text({ access: { read: ({ session, item }) => session?.userId === item.id, }, }), }Validate on Both Client and Server Always validate in server actions, even if client validates:
typescript'use server' export async function createPost(data: unknown) { // Server-side validation const validated = postSchema.parse(data) // ... }
Session Management
Configure Session Expiration
typescriptauthPlugin({ session: { expiresIn: 604800, // 7 days updateAge: true, // Extend expiry on each request }, })Include Only Necessary Fields Don't include sensitive data in session fields:
typescriptsessionFields: ['userId', 'email', 'name', 'role'], // Good sessionFields: ['userId', 'password', 'apiKey'], // Bad!
Project Structure
Recommended structure for auth-enabled apps:
app/
├── sign-in/
│ └── page.tsx # Sign in page
├── sign-up/
│ └── page.tsx # Sign up page
├── forgot-password/
│ └── page.tsx # Password reset request
├── admin/
│ └── [[...admin]]/
│ └── page.tsx # Protected admin area
└── api/
└── auth/
└── [...all]/
└── route.ts # Auth API routes
lib/
├── auth.ts # Server auth instance
├── auth-client.ts # Client auth instance
└── actions/
└── *.ts # Server actions with auth
opensaas.config.ts # Config with authPlugin
Environment Variables
Always use environment variables for sensitive data:
// Good
authPlugin({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
})
// Bad
authPlugin({
socialProviders: {
github: {
clientId: 'hardcoded_client_id', // Never do this!
clientSecret: 'hardcoded_secret',
},
},
})
Error Handling
Handle auth errors gracefully:
'use client'
export function SignInButton() {
const [error, setError] = useState<string | null>(null)
async function handleSignIn(email: string, password: string) {
try {
await authClient.signIn.email({ email, password })
router.push('/admin')
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('An error occurred')
}
}
}
return (
<div>
{error && <div className="text-red-500">{error}</div>}
{/* Form */}
</div>
)
}
Complete Example
Here's a complete working example of an authenticated blog application:
// opensaas.config.ts
import { config, list, text, select, relationship } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
import type { AccessControl } from '@opensaas/stack-core'
// Access control helpers
const isSignedIn: AccessControl = ({ session }) => !!session
const isAuthor: AccessControl = ({ session, item }) => {
if (!session) return false
return { authorId: { equals: session.userId } }
}
export default config({
plugins: [
authPlugin({
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
passwordReset: {
enabled: true,
},
sessionFields: ['userId', 'email', 'name'],
extendUserList: {
fields: {
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
},
}),
],
db: {
provider: 'sqlite',
url: process.env.DATABASE_URL || 'file:./dev.db',
},
lists: {
Post: list({
fields: {
title: text({
validation: { isRequired: true },
}),
content: text({
validation: { isRequired: true },
}),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
author: relationship({
ref: 'User.posts',
}),
},
access: {
operation: {
query: () => true,
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
filter: {
query: ({ session }) => {
if (!session) {
return { status: { equals: 'published' } }
}
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
},
},
},
}),
},
})
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
import { headers } from 'next/headers'
export const auth = createAuth(config, rawOpensaasContext)
export async function getAuth() {
const session = await auth.api.getSession({
headers: await headers(),
})
return session
}
export const GET = auth.handler
export const POST = auth.handler
// lib/auth-client.ts
'use client'
import { createClient } from '@opensaas/stack-auth/client'
export const authClient = createClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function SignInPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign In</h1>
<SignInForm
authClient={authClient}
redirectTo="/admin"
/>
</div>
)
}
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import { getContext, config } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage({ params, searchParams }) {
const session = await getAuth()
if (!session) {
redirect('/sign-in')
}
return (
<AdminUI
context={await getContext(session.user)}
config={await config}
params={params.admin}
searchParams={searchParams}
/>
)
}
Troubleshooting
"Session is null" in Access Control
Make sure you're passing the session when creating the context:
// ❌ Wrong
const context = await getContext()
// ✅ Correct
const session = await getAuth()
const context = await getContext(session?.user)
OAuth Redirect Not Working
Check your OAuth app configuration:
- Callback URL must match exactly:
http://localhost:3000/api/auth/callback/{provider} - Environment variables are set correctly
- App is approved/published (for Google)
Email Verification Not Sending
- Check if
sendEmailfunction is configured - In development, check console for email logs
- Verify email provider API keys are set
"Access Denied" on All Operations
Check your access control configuration:
- Does the operation return
trueor a filter? - Is the session being passed correctly?
- Are you checking the right session fields?
TypeScript Errors on Session Fields
Make sure custom fields are included in sessionFields:
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role'], // Include 'role'
extendUserList: {
fields: {
role: select({
/* ... */
}),
},
},
})
Next Steps
- Package Reference: See the Auth Package docs for detailed API reference
- Access Control Guide: Learn more in the Access Control Guide
- Working Example: Check out the auth-demo example
- Better Auth Docs: Explore Better-auth documentation for advanced features
You now have everything you need to implement secure authentication in your OpenSaaS Stack application!