- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
OAuth providers (Google OIDC, Facebook OAuth 2.0) feed through the multi-tenant Prisma adapter and reuse the JWT/session callbacks documented in authentication. This page covers the OAuth-specific pieces.
Flow
User clicks button (login or join)
→ Social component onClick(provider)
→ store callback URL (3 layers: server cookie, sessionStorage, client cookie)
→ signIn(provider, { callbackUrl, state })
→ Provider (Google / Facebook) → consent → redirect
→ /api/auth/callback/{provider}
→ MultiTenantPrismaAdapter creates / links user
→ JWT issued, session populated
→ redirect callback resolves destination
The <Social /> component (src/components/auth/social.tsx) is used by both /login and /join.
Social component
Two branches in onClick(provider):
Subdomain context
When on a subdomain (e.g. demo.localhost:3000) or ?tenant=:
- Extract subdomain from hostname or
tenantsearch param. - Store in
sessionStorage(oauth_tenant,oauth_callback_url). signIn(provider, { callbackUrl: dashboardUrl?tenant=... }).
const isProdSubdomain =
currentHost.endsWith(".databayt.org") && currentHost !== "ed.databayt.org"
const isDevSubdomain =
currentHost.includes(".localhost") && currentHost !== "localhost"Main domain
When on ed.databayt.org or localhost:3000:
- Resolve
callbackUrlfrom search params or fall back toDEFAULT_LOGIN_REDIRECT. - Triple-store callback URL.
- Add base64-encoded
state{ callbackUrl, timestamp }. signIn(provider, { callbackUrl, redirect: true, state }).
Facebook #_=_ cleanup
Facebook appends #_=_ to the redirect. cleanUrlHash() strips it on mount; the redirect callback strips it again server-side.
if (window.location.hash === "#_=_") {
const cleanUrl = window.location.href.replace(/#.*$/, "")
window.history.replaceState({}, document.title, cleanUrl)
}Providers
src/auth.config.ts registers Google and Facebook. The canonical pattern (Google):
Google({
clientId: env.GOOGLE_CLIENT_ID || "",
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code",
},
},
profile(profile) {
return {
id: profile.sub,
username: profile.name,
email: profile.email,
image: profile.picture,
emailVerified: new Date(), // Google emails pre-verified
}
},
})Google forces prompt: "consent" + access_type: "offline" for refresh tokens. Facebook follows the same shape with clientId/clientSecret; it does not require a custom profile mapper.
Multi-tenant adapter
The User model uses @@unique([email, schoolId]), not @unique email. The standard adapter's findUnique({ where: { email } }) fails. MultiTenantPrismaAdapter overrides four methods:
| Method | Behavior |
|---|---|
getUserByEmail(email) | findFirst({ where: { email, schoolId: null } }) — finds OAuth users not yet assigned to a school |
createUser(data) | Creates with schoolId: null, maps name → username |
getUserByAccount({ provider, providerAccountId }) | Lookup via Account's @@unique([provider, providerAccountId]) |
linkAccount(account) | Creates Account row linking provider to user |
OAuth users start with schoolId: null; assigned during onboarding.
Account linking
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}First sign-in:
getUserByAccount— not found.getUserByEmail— checks for existing user (schoolId: null).- If exists →
linkAccount. Otherwise →createUserthenlinkAccount.
Repeat sign-in: getUserByAccount finds the row, returns the user, no writes.
OAuthAccountNotLinked: returned when a credentials user tries OAuth with the same email — security feature; user must sign in with their original provider first.
Callback URL preservation
OAuth bounces the user to the provider, then back to /api/auth/callback/{provider} — the original destination is lost. social.tsx triple-stores it before redirect:
| Layer | Storage | Survives redirect? |
|---|---|---|
| 1 | Server httpOnly cookie via POST /api/auth/store-callback | Yes |
| 2 | sessionStorage[oauth_callback_intended] | No (different origin) |
| 3 | Client cookie oauth_callback_intended (15 min, SameSite=Lax) | Yes |
// /api/auth/store-callback
cookieStore.set({
name: "oauth_callback_intended",
value: callbackUrl,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 900,
path: "/",
})resolveCallbackUrl() (in auth.ts) reads back, first match wins:
- Server cookie
oauth_callback_intended - URL
searchParams(callbackUrl,redirect) - Regex match for
callbackUrl=... baseUrlsearchParams
JWT and session for OAuth
src/auth.ts callbacks:
| Trigger | Behavior |
|---|---|
signIn (initial) | Receives AdapterUser with id, email, name only — no role/schoolId yet. Sets token id, provider, sessionToken, hash. |
| No trigger (subsequent) | Detects !token.schoolId && token.id → refreshes from DB. Picks up role/schoolId once user record updates (post-onboarding). |
update (manual) | Loads schoolId and role from DB — used after onboarding assigns the user. |
signIn callback always returns true. Provider checks: Facebook logs error if no email (missing email scope); Google logs warning if email_verified is false.
Key implication: the first request after OAuth login may have an incomplete session (no role/schoolId); it auto-refreshes from the DB on the next request.
Redirect callback
Full priority chain in authentication. OAuth-relevant helpers:
| Function | Purpose |
|---|---|
resolveCallbackUrl(url, baseUrl) | Multi-source resolution |
validateCallbackUrl(callbackUrl, baseUrl) | Same-origin security check |
detectSubdomainFromHost(host) | Extract subdomain (null for main domain) |
getSmartRedirectUrl(url, baseUrl) | JWT → school lookup → subdomain URL |
constructSchoolUrl(subdomain, path) | Environment-aware URL builder |
extractLocaleFromUrl(url) | Extract ar or en (default ar) |
Cookie configuration
All auth cookies share domain: ".databayt.org" in production for SSO across ed.databayt.org and every school subdomain. Development uses domain: undefined — scoped to single hostname.
| Cookie | Name | httpOnly | maxAge | Purpose |
|---|---|---|---|---|
| PKCE verifier | authjs.pkce.code_verifier | Yes | 15min | OAuth PKCE flow |
| Session token | authjs.session-token | Yes | 24h | JWT session |
| Callback URL | authjs.callback-url | No | — | NextAuth callback |
| CSRF token | authjs.csrf-token | Yes | — | CSRF protection |
| State | authjs.state | Yes | — | OAuth state |
| Nonce | authjs.nonce | Yes | — | OIDC nonce |
sameSite: "lax" everywhere (required for OAuth redirects); secure: true in production. trustHost: true is required for Vercel proxy environments.
Environment
| Variable | Required | Validation | Used by |
|---|---|---|---|
GOOGLE_CLIENT_ID | For Google | t3-env optional, ends .apps.googleusercontent.com | auth.config.ts via env |
GOOGLE_CLIENT_SECRET | For Google | t3-env optional | auth.config.ts via env |
FACEBOOK_CLIENT_ID | For Facebook | t3-env optional, numeric | auth.config.ts via process.env |
FACEBOOK_CLIENT_SECRET | For Facebook | t3-env optional | auth.config.ts via process.env |
AUTH_SECRET | Yes | Min 32 chars | NextAuth core |
NEXTAUTH_URL | Recommended | Inferred in v5 | NextAuth |
NEXT_PUBLIC_APP_URL | Yes | Required | Email links, callbacks |
auth-config-validator.ts validates at module load.
Errors
OAuth failures redirect to /error?error={code}. ErrorCard (src/components/auth/error-card.tsx) maps codes:
| Code | Trigger |
|---|---|
Configuration | Missing/invalid provider config |
AccessDenied | Provider denied access |
OAuthSignin | Failed to start OAuth flow |
OAuthCallback | Callback processing failed |
OAuthCreateAccount | DB error during user creation |
OAuthAccountNotLinked | Credentials user tried OAuth with same email |
Callback | Generic callback error |
default | Anything else |
File map
UI
src/components/auth/social.tsx # Social login buttons
src/components/auth/error-card.tsx # Error display
Config
src/auth.config.ts # Provider configs
src/auth.ts # Callbacks, cookie config
Adapter
src/lib/multi-tenant-prisma-adapter.ts
API
src/app/api/auth/store-callback/route.ts
src/app/api/auth/[...nextauth]/route.ts
Validation
src/lib/auth-config-validator.ts
src/env.mjs
Middleware
src/proxy.ts # Skips /api/auth/*
Routes / models / pages
src/routes.ts
prisma/models/auth.prisma # User, Account
src/app/[lang]/(auth)/{error,login,join}/page.tsx