AUTH IMPLEMENTATION PLAN - nself-org/nchat GitHub Wiki
Document Version: 1.0.0 Last Updated: February 3, 2026 Relates to: TODO.md Tasks 86-91 (Auth & Identity) Target Version: v1.0.0
This document provides a comprehensive implementation plan for nChat's authentication system. The goal is to build a robust, secure, and flexible authentication layer that supports multiple providers, two-factor authentication, enterprise SSO, and maintains backward compatibility with existing development authentication.
- Current State Analysis
- Architecture Overview
- Authentication Providers
- API Endpoints Specification
- Two-Factor Authentication
- Enterprise SSO
- Development Auth Safeguards
- nself Auth Service Integration
- Implementation Phases
- Security Considerations
- Testing Strategy
- Migration Guide
| File | Status | Description |
|---|---|---|
auth.interface.ts |
Working | Defines AuthUser, AuthResponse, and AuthService interfaces |
auth-plugin.interface.ts |
Working | Comprehensive plugin system with AuthProvider, BaseAuthProvider, and AuthProviderRegistry
|
faux-auth.service.ts |
Working | Development authentication with 8 predefined test users |
nhost-auth.service.ts |
Partial | Nhost SDK integration (basic sign in/up/out works) |
real-auth.service.ts |
Partial | Direct HTTP auth service (token refresh works) |
database-auth.service.ts |
Partial | Direct PostgreSQL auth with bcrypt verification |
| Provider | Status | Notes |
|---|---|---|
email-password.provider.ts |
Scaffold | Basic structure, needs Nhost integration |
magic-link.provider.ts |
Scaffold | Basic structure, needs backend |
google.provider.ts |
Working | Full OAuth flow implemented |
github.provider.ts |
Scaffold | Similar to Google, needs testing |
apple.provider.ts |
Scaffold | Needs Apple Developer setup |
microsoft.provider.ts |
Scaffold | Basic structure |
facebook.provider.ts |
Scaffold | Basic structure |
twitter.provider.ts |
Scaffold | Basic structure |
phone-sms.provider.ts |
Scaffold | Basic structure |
whatsapp.provider.ts |
Scaffold | Basic structure |
telegram.provider.ts |
Scaffold | Basic structure |
idme.provider.ts |
Working | Full implementation with verification groups |
| Endpoint | Status | Notes |
|---|---|---|
signin/route.ts |
Working | Full implementation with bcrypt, JWT, rate limiting |
signup/route.ts |
Partial | Dev mode mock, production needs completion |
change-password/route.ts |
Partial | Dev mode mock, production stub |
verify-password/route.ts |
Scaffold | Not implemented |
oauth/callback/route.ts |
Partial | Basic redirect handling |
oauth/connect/route.ts |
Scaffold | Account linking stub |
sessions/route.ts |
Working | Full CRUD with GraphQL |
sessions/activity/route.ts |
Scaffold | Activity tracking |
2fa/setup/route.ts |
Working | TOTP secret generation, QR codes |
2fa/verify/route.ts |
Working | TOTP and backup code verification |
2fa/verify-setup/route.ts |
Scaffold | Setup verification |
2fa/status/route.ts |
Scaffold | 2FA status check |
2fa/backup-codes/route.ts |
Scaffold | Backup code management |
2fa/disable/route.ts |
Scaffold | Disable 2FA |
2fa/trusted-devices/route.ts |
Scaffold | Device trust management |
| File | Status | Description |
|---|---|---|
totp.ts |
Working | TOTP generation/verification with speakeasy |
backup-codes.ts |
Working | Generation, hashing, verification with bcrypt |
device-fingerprint.ts |
Working | Device trust management |
| Migration | Status | Description |
|---|---|---|
014_real_auth_users.sql |
Working | Auth users with bcrypt passwords |
015_2fa_system.sql |
Working | Complete 2FA tables and RLS policies |
- Password Reset Flow - No forgot-password or reset-password endpoints
- Email Verification - No verify-email endpoint
- OAuth Token Exchange - Callback doesn't complete token exchange
- Account Linking - OAuth account linking not implemented
- Enterprise SSO - SAML/OIDC not implemented
- Session Refresh - Token refresh needs production testing
- Rate Limiting - Applied to signin, needs broader coverage
- Audit Logging - Auth events not logged to audit table
+------------------+
| Client (Web) |
+--------+---------+
|
+------------------------+------------------------+
| | |
Email/Password OAuth/Social Enterprise SSO
| | |
v v v
+-------+--------+ +--------+--------+ +--------+--------+
| /api/auth/ | | Provider Auth | | SAML/OIDC |
| signin | | (Google, etc.) | | IdP |
+-------+--------+ +--------+--------+ +--------+--------+
| | |
+------------------------+------------------------+
|
v
+-----------------------------+
| Auth Middleware |
| - Validate credentials |
| - Check 2FA requirement |
| - Rate limiting |
+-------------+---------------+
|
+---------------+---------------+
| |
2FA Required? No 2FA Required
| |
v |
+--------------+ |
| 2FA Verify | |
| - TOTP | |
| - Backup | |
+--------------+ |
| |
+---------------+---------------+
|
v
+-----------------------------+
| Session Manager |
| - Create session |
| - Issue JWT tokens |
| - Set cookies |
+-------------+---------------+
|
v
+-----------------------------+
| Database (Hasura) |
| - auth.users |
| - nchat_user_sessions |
| - nchat_user_2fa_settings |
+-----------------------------+
// Recommended service structure
src/services/auth/
├── index.ts // Main export, service factory
├── auth.interface.ts // Core interfaces
├── auth-plugin.interface.ts // Plugin system
├── auth-factory.ts // Service factory based on env
├── services/
│ ├── faux-auth.service.ts // Development auth
│ ├── nhost-auth.service.ts // Nhost SDK wrapper
│ └── database-auth.service.ts // Direct DB auth
├── providers/
│ ├── index.ts // Provider registry
│ ├── email-password.provider.ts
│ ├── magic-link.provider.ts
│ ├── google.provider.ts
│ ├── github.provider.ts
│ ├── apple.provider.ts
│ ├── microsoft.provider.ts
│ ├── discord.provider.ts
│ ├── idme.provider.ts
│ └── saml.provider.ts // NEW: Enterprise SSO
├── middleware/
│ ├── rate-limiter.ts
│ ├── auth-guard.ts
│ └── 2fa-guard.ts
└── utils/
├── jwt.ts
├── password.ts
└── session.tsImplementation using Nhost Auth:
// src/services/auth/providers/email-password.provider.ts
export class EmailPasswordProvider extends BaseAuthProvider {
async signIn(credentials: EmailPasswordCredentials): Promise<AuthResult> {
const { email, password } = credentials
// Use Nhost SDK
const { session, error } = await nhost.auth.signIn({
email,
password,
})
if (error) {
return this.createErrorResult(this.createError('AUTH_FAILED', error.message))
}
// Check if 2FA is required
const requires2FA = await this.check2FARequired(session.user.id)
if (requires2FA) {
return {
success: true,
requires2FA: true,
userId: session.user.id,
tempToken: this.generateTempToken(session.user.id),
}
}
return this.createSuccessResult(
this.mapNhostUser(session.user),
session.accessToken,
session.refreshToken
)
}
async signUp(
credentials: EmailPasswordCredentials,
metadata?: Record<string, unknown>
): Promise<AuthResult> {
const { email, password } = credentials
const { session, error } = await nhost.auth.signUp({
email,
password,
options: {
displayName: metadata?.displayName as string,
metadata: {
username: metadata?.username,
},
},
})
if (error) {
return this.createErrorResult(this.createError('SIGNUP_FAILED', error.message))
}
// Send verification email if required
if (this.config.requireEmailVerification) {
await nhost.auth.sendVerificationEmail({ email })
}
return this.createSuccessResult(
this.mapNhostUser(session.user),
session.accessToken,
session.refreshToken
)
}
}Supported Providers:
| Provider | Client ID Env Var | Scopes | Notes |
|---|---|---|---|
GOOGLE_CLIENT_ID |
openid, email, profile | Most common | |
| GitHub | GITHUB_CLIENT_ID |
user:email, read:user | Developer-focused |
| Microsoft | MICROSOFT_CLIENT_ID |
openid, email, profile | Enterprise |
| Apple | APPLE_CLIENT_ID |
name, email | iOS users |
| Discord | DISCORD_CLIENT_ID |
identify, email | Gaming communities |
FACEBOOK_CLIENT_ID |
email, public_profile | Deprecated support | |
| Twitter/X | TWITTER_CLIENT_ID |
tweet.read, users.read | OAuth 2.0 |
OAuth Flow Implementation:
// Generic OAuth callback handler
// src/app/api/auth/oauth/[provider]/callback/route.ts
export async function GET(request: NextRequest, { params }: { params: { provider: string } }) {
const { provider } = params
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (error) {
return redirectWithError(error)
}
// Validate state to prevent CSRF
const storedState = await getStoredState(state)
if (!storedState || storedState.provider !== provider) {
return redirectWithError('invalid_state')
}
try {
// Exchange code for tokens via Nhost
const { session, error: authError } = await nhost.auth.signIn({
provider: provider as Provider,
options: {
redirectTo: storedState.redirectTo,
},
})
if (authError) {
return redirectWithError(authError.message)
}
// Create/update nchat user
await upsertNchatUser(session.user)
// Check 2FA requirement
const requires2FA = await check2FARequired(session.user.id)
if (requires2FA) {
return redirectTo2FA(session.user.id)
}
// Set session cookies and redirect
return redirectWithSession(session)
} catch (error) {
return redirectWithError('callback_failed')
}
}ID.me provides government-grade identity verification for military, first responders, and government employees.
Verified Groups:
-
military- Active duty military -
veteran- Military veterans -
military-family- Military family members -
first-responder- Police, fire, EMT -
nurse- Licensed nurses -
hospital- Hospital workers -
government- Government employees -
teacher- K-12 teachers -
student- College students
Implementation (Already exists at src/services/auth/providers/idme.provider.ts):
The ID.me provider is fully implemented with:
- OAuth 2.0 flow with ID.me
- Verification status checking
- Group membership validation
- Automatic role assignment based on verification
Configuration:
// AppConfig.authProviders.idme
{
enabled: boolean
allowMilitary: boolean
allowPolice: boolean
allowFirstResponders: boolean
allowGovernment: boolean
requireVerification: boolean
}Request:
{
"email": "[email protected]",
"password": "SecureP@ss123",
"username": "johndoe",
"displayName": "John Doe"
}Response (Success):
{
"success": true,
"user": {
"id": "uuid",
"email": "[email protected]",
"username": "johndoe",
"displayName": "John Doe",
"role": "member",
"emailVerified": false
},
"accessToken": "jwt...",
"refreshToken": "jwt...",
"requiresEmailVerification": true
}Implementation:
// src/app/api/auth/signup/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { nhost } from '@/lib/nhost'
import { withErrorHandler, withRateLimit, compose } from '@/lib/api/middleware'
import { validatePassword, validateEmail } from '@/lib/auth/validators'
const RATE_LIMIT = { limit: 3, window: 60 * 60 } // 3 per hour
async function handleSignUp(request: NextRequest): Promise<NextResponse> {
const { email, password, username, displayName } = await request.json()
// Validate inputs
const emailValidation = validateEmail(email)
if (!emailValidation.valid) {
return badRequestResponse(emailValidation.error)
}
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
return badRequestResponse(passwordValidation.error)
}
// Check if email already exists
const existingUser = await checkExistingUser(email)
if (existingUser) {
return conflictResponse('Email already registered')
}
// Sign up via Nhost
const { session, error } = await nhost.auth.signUp({
email,
password,
options: {
displayName,
metadata: { username },
},
})
if (error) {
return badRequestResponse(error.message)
}
// Determine role (first user becomes owner)
const isFirstUser = await checkIfFirstUser()
const role = isFirstUser ? 'owner' : 'member'
// Create nchat user record
await createNchatUser({
authUserId: session.user.id,
username,
displayName,
email,
role,
})
return successResponse({
user: {
id: session.user.id,
email,
username,
displayName,
role,
emailVerified: false,
},
accessToken: session.accessToken,
refreshToken: session.refreshToken,
requiresEmailVerification: true,
})
}
export const POST = compose(withErrorHandler, withRateLimit(RATE_LIMIT))(handleSignUp)Request:
{
"email": "[email protected]",
"password": "SecureP@ss123",
"rememberMe": true
}Response (Success - No 2FA):
{
"success": true,
"user": { ... },
"accessToken": "jwt...",
"refreshToken": "jwt..."
}Response (Success - 2FA Required):
{
"success": true,
"requires2FA": true,
"tempToken": "temp-jwt...",
"available2FAMethods": ["totp", "backup_code"]
}Status: Already implemented at src/app/api/auth/signin/route.ts
Request:
{
"refreshToken": "jwt...",
"revokeAllSessions": false
}Response:
{
"success": true,
"message": "Signed out successfully"
}Implementation:
// src/app/api/auth/signout/route.ts
export async function POST(request: NextRequest): Promise<NextResponse> {
const { refreshToken, revokeAllSessions } = await request.json()
// Get user from token
const userId = await getUserIdFromToken(refreshToken)
if (!userId) {
return unauthorizedResponse('Invalid token')
}
// Revoke session(s)
if (revokeAllSessions) {
await revokeAllUserSessions(userId)
} else {
await revokeSession(refreshToken)
}
// Sign out from Nhost
await nhost.auth.signOut()
// Clear cookies
const response = successResponse({ success: true })
response.cookies.delete('nchat-session')
response.cookies.delete('nchat-refresh')
return response
}Request:
{
"email": "[email protected]"
}Response:
{
"success": true,
"message": "If an account exists, a reset link has been sent"
}Implementation:
// src/app/api/auth/forgot-password/route.ts
const RATE_LIMIT = { limit: 3, window: 15 * 60 } // 3 per 15 minutes
async function handleForgotPassword(request: NextRequest): Promise<NextResponse> {
const { email } = await request.json()
// Always return success to prevent email enumeration
const response = {
success: true,
message: 'If an account exists, a reset link has been sent',
}
// Check if user exists (don't expose result)
const user = await getUserByEmail(email)
if (!user) {
return successResponse(response)
}
// Generate reset token
const resetToken = await generatePasswordResetToken(user.id)
// Send reset email
await sendPasswordResetEmail({
to: email,
resetToken,
expiresIn: '1 hour',
})
// Log for audit
await logAuthEvent({
type: 'PASSWORD_RESET_REQUESTED',
userId: user.id,
email,
ipAddress: getClientIP(request),
})
return successResponse(response)
}
export const POST = compose(withErrorHandler, withRateLimit(RATE_LIMIT))(handleForgotPassword)Request:
{
"token": "reset-token...",
"newPassword": "NewSecureP@ss456"
}Response:
{
"success": true,
"message": "Password reset successfully"
}Implementation:
// src/app/api/auth/reset-password/route.ts
async function handleResetPassword(request: NextRequest): Promise<NextResponse> {
const { token, newPassword } = await request.json()
// Validate token
const tokenData = await validatePasswordResetToken(token)
if (!tokenData.valid) {
return badRequestResponse('Invalid or expired reset token')
}
// Validate new password
const passwordValidation = validatePassword(newPassword)
if (!passwordValidation.valid) {
return badRequestResponse(passwordValidation.error)
}
// Check password not same as current
const isSamePassword = await checkSamePassword(tokenData.userId, newPassword)
if (isSamePassword) {
return badRequestResponse('New password must be different from current password')
}
// Update password via Nhost
await nhost.auth.changePassword({ newPassword })
// Invalidate all sessions
await revokeAllUserSessions(tokenData.userId)
// Mark token as used
await invalidatePasswordResetToken(token)
// Log for audit
await logAuthEvent({
type: 'PASSWORD_RESET_COMPLETED',
userId: tokenData.userId,
})
return successResponse({
success: true,
message: 'Password reset successfully. Please sign in with your new password.',
})
}Request:
{
"token": "verification-token..."
}Response:
{
"success": true,
"message": "Email verified successfully"
}Implementation:
// src/app/api/auth/verify-email/route.ts
export async function POST(request: NextRequest): Promise<NextResponse> {
const { token } = await request.json()
// Verify token via Nhost
const { error } = await nhost.auth.verifyEmail({ token })
if (error) {
return badRequestResponse('Invalid or expired verification token')
}
return successResponse({
success: true,
message: 'Email verified successfully',
})
}
// Also support GET for email links
export async function GET(request: NextRequest): Promise<NextResponse> {
const token = request.nextUrl.searchParams.get('token')
if (!token) {
return redirectWithError('Missing verification token')
}
const { error } = await nhost.auth.verifyEmail({ token })
if (error) {
return redirect('/auth/verify-email?error=invalid_token')
}
return redirect('/auth/verify-email?success=true')
}Response:
{
"providers": [
{
"id": "email-password",
"name": "Email & Password",
"type": "email",
"enabled": true
},
{
"id": "google",
"name": "Google",
"type": "social",
"enabled": true,
"authUrl": "/api/auth/oauth/google"
},
{
"id": "github",
"name": "GitHub",
"type": "social",
"enabled": true,
"authUrl": "/api/auth/oauth/github"
},
{
"id": "idme",
"name": "ID.me",
"type": "verification",
"enabled": true,
"authUrl": "/api/auth/oauth/idme",
"verificationGroups": ["military", "first-responder", "government"]
}
]
}Implementation:
// src/app/api/auth/providers/route.ts
export async function GET(): Promise<NextResponse> {
// Get enabled providers from AppConfig
const config = await getAppConfig()
const enabledProviders = getEnabledProviders(config.authProviders)
const providers = enabledProviders.map((providerId) => {
const provider = authProviders[providerId]
return {
id: providerId,
name: providerNames[providerId],
type: provider.metadata.type,
enabled: true,
authUrl:
provider.metadata.type === 'social' || provider.metadata.type === 'verification'
? `/api/auth/oauth/${providerId}`
: undefined,
...(providerId === 'idme' && {
verificationGroups: config.authProviders.idme.allowedGroups,
}),
}
})
return successResponse({ providers })
}Request:
{
"redirectTo": "/chat",
"linkToAccount": false
}Response:
{
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?..."
}The 2FA system supports:
- TOTP (Time-based One-Time Password) - Google Authenticator compatible
- Backup Codes - 10 single-use recovery codes
- Trusted Devices - Remember device for 30 days
- SMS Fallback (Optional) - Requires Twilio integration
Already implemented at .backend/migrations/015_2fa_system.sql:
-- nchat_user_2fa_settings - TOTP secrets
-- nchat_user_backup_codes - Recovery codes (hashed)
-- nchat_user_trusted_devices - Device fingerprints
-- nchat_2fa_verification_attempts - Rate limitingStep 1: Generate Setup Data
// src/app/api/auth/2fa/setup/route.ts (Already implemented)
export async function POST(request: NextRequest) {
const { userId, email } = await request.json()
// Generate TOTP secret
const { base32, otpauthUrl } = generateTOTPSecret({
name: email,
issuer: 'nchat',
})
// Generate QR code
const qrCodeDataUrl = await generateQRCode(otpauthUrl)
// Generate backup codes
const backupCodes = generateBackupCodes(10)
// Store temporarily (not enabled yet)
await storePending2FASetup(userId, base32, backupCodes)
return NextResponse.json({
success: true,
data: {
secret: base32,
qrCodeDataUrl,
otpauthUrl,
backupCodes,
manualEntryCode: formatSecretForDisplay(base32),
},
})
}Step 2: Verify and Enable
// src/app/api/auth/2fa/verify-setup/route.ts
export async function POST(request: NextRequest) {
const { userId, code } = await request.json()
// Get pending setup
const pendingSetup = await getPending2FASetup(userId)
if (!pendingSetup) {
return badRequestResponse('No pending 2FA setup found')
}
// Verify the TOTP code
const isValid = verifyTOTP(code, pendingSetup.secret)
if (!isValid) {
return badRequestResponse('Invalid verification code')
}
// Enable 2FA
await enable2FA(userId, pendingSetup.secret)
// Store hashed backup codes
await storeBackupCodes(userId, pendingSetup.backupCodes)
// Clear pending setup
await clearPending2FASetup(userId)
return successResponse({
success: true,
message: '2FA enabled successfully',
})
}Already implemented at src/app/api/auth/2fa/verify/route.ts
// src/app/api/auth/2fa/disable/route.ts
export async function POST(request: NextRequest) {
const { userId, password, code } = await request.json()
// Verify password
const passwordValid = await verifyUserPassword(userId, password)
if (!passwordValid) {
return unauthorizedResponse('Invalid password')
}
// Verify 2FA code (current code required to disable)
const settings = await get2FASettings(userId)
const codeValid = verifyTOTP(code, settings.secret)
if (!codeValid) {
return badRequestResponse('Invalid 2FA code')
}
// Disable 2FA
await disable2FA(userId)
// Delete backup codes
await deleteBackupCodes(userId)
// Delete trusted devices
await deleteTrustedDevices(userId)
// Log for audit
await logAuthEvent({
type: '2FA_DISABLED',
userId,
})
return successResponse({
success: true,
message: '2FA disabled successfully',
})
}// src/app/api/auth/2fa/backup-codes/route.ts
export async function POST(request: NextRequest) {
const { userId, password } = await request.json()
// Verify password
const passwordValid = await verifyUserPassword(userId, password)
if (!passwordValid) {
return unauthorizedResponse('Invalid password')
}
// Generate new backup codes
const newCodes = await generateAndHashBackupCodes(10)
// Replace existing codes
await replaceBackupCodes(userId, newCodes)
return successResponse({
success: true,
backupCodes: newCodes.map((c) => c.code),
message: 'New backup codes generated. Old codes are now invalid.',
})
}
// GET - Get remaining code count
export async function GET(request: NextRequest) {
const userId = request.nextUrl.searchParams.get('userId')
const remaining = await countRemainingBackupCodes(userId)
return successResponse({
remaining,
shouldRegenerate: remaining <= 3,
})
}// src/lib/2fa/sms.ts
import twilio from 'twilio'
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
export async function sendSMS2FACode(phoneNumber: string): Promise<string> {
const code = generateNumericCode(6)
// Store code with expiry (5 minutes)
await storePhoneVerificationCode(phoneNumber, code, 5 * 60)
// Send SMS
await client.messages.create({
body: `Your nchat verification code is: ${code}`,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber,
})
return code
}
export async function verifySMS2FACode(phoneNumber: string, code: string): Promise<boolean> {
const storedCode = await getPhoneVerificationCode(phoneNumber)
if (!storedCode || storedCode !== code) {
return false
}
// Clear used code
await clearPhoneVerificationCode(phoneNumber)
return true
}| Provider | Protocol | Configuration |
|---|---|---|
| Okta | SAML 2.0 / OIDC | Metadata URL or manual |
| Azure AD | SAML 2.0 / OIDC | Tenant ID + App ID |
| Google Workspace | OIDC | Domain verification |
| OneLogin | SAML 2.0 | Connector ID |
| Auth0 | OIDC | Domain + Client ID |
| Ping Identity | SAML 2.0 | Entity ID |
| JumpCloud | SAML 2.0 | Organization ID |
| Generic SAML | SAML 2.0 | Manual configuration |
// src/services/auth/providers/saml.provider.ts
import { SAML } from '@node-saml/node-saml'
export interface SAMLConfig extends AuthProviderConfig {
entryPoint: string // IdP SSO URL
issuer: string // SP Entity ID
cert: string // IdP Certificate
callbackUrl: string // ACS URL
signatureAlgorithm?: 'sha256' | 'sha512'
wantAssertionsSigned?: boolean
attributeMapping?: {
email: string
firstName?: string
lastName?: string
groups?: string
}
}
export class SAMLProvider extends BaseAuthProvider {
private saml: SAML
readonly metadata: AuthProviderMetadata = {
id: 'saml',
name: 'Enterprise SSO',
type: 'enterprise',
icon: 'building-2',
description: 'Sign in with your organization account',
requiresBackend: true,
supportedFeatures: {
signIn: true,
signUp: true, // JIT provisioning
signOut: true, // SLO support
tokenRefresh: false,
passwordReset: false,
emailVerification: false,
phoneVerification: false,
mfa: false, // IdP handles MFA
linkAccount: true,
},
}
async initialize(config: SAMLConfig): Promise<void> {
await super.initialize(config)
this.saml = new SAML({
entryPoint: config.entryPoint,
issuer: config.issuer,
cert: config.cert,
callbackUrl: config.callbackUrl,
signatureAlgorithm: config.signatureAlgorithm || 'sha256',
wantAssertionsSigned: config.wantAssertionsSigned ?? true,
})
}
async getAuthorizationUrl(): Promise<{ url: string; state: OAuthState }> {
const state = this.generateState()
const url = await this.saml.getAuthorizeUrlAsync(state.state, { additionalParams: {} })
return { url, state }
}
async handleCallback(samlResponse: string): Promise<AuthResult> {
try {
const { profile } = await this.saml.validatePostResponseAsync(samlResponse)
// Extract user attributes based on mapping
const mapping = (this.config as SAMLConfig).attributeMapping
const user = this.mapSAMLProfile(profile, mapping)
// JIT Provisioning - create/update user
const nchatUser = await this.jitProvision(user)
// Generate session tokens
const session = await this.createSession(nchatUser)
return this.createSuccessResult(nchatUser, session.accessToken, session.refreshToken)
} catch (error) {
return this.createErrorResult(this.createError('SAML_ERROR', error.message))
}
}
private async jitProvision(samlUser: SAMLUser): Promise<AuthUser> {
// Check if user exists
const existing = await getUserByEmail(samlUser.email)
if (existing) {
// Update existing user
await updateUser(existing.id, {
displayName: samlUser.displayName,
metadata: {
...existing.metadata,
samlGroups: samlUser.groups,
lastSAMLLogin: new Date().toISOString(),
},
})
return existing
}
// Create new user (JIT provisioning)
const defaultRole = this.config.defaultRole || 'member'
return await createNchatUser({
email: samlUser.email,
username: samlUser.email.split('@')[0],
displayName: samlUser.displayName,
role: defaultRole,
metadata: {
provider: 'saml',
samlGroups: samlUser.groups,
provisionedAt: new Date().toISOString(),
},
})
}
// Generate SP metadata for IdP configuration
async getServiceProviderMetadata(): Promise<string> {
return this.saml.generateServiceProviderMetadata(
null, // No decryption cert
null // No signing cert (use IdP's)
)
}
}// src/services/auth/providers/oidc.provider.ts
import { Issuer, Client, generators } from 'openid-client'
export interface OIDCConfig extends AuthProviderConfig {
issuerUrl: string // IdP discovery URL
clientId: string
clientSecret: string
scopes?: string[]
attributeMapping?: {
email?: string
name?: string
groups?: string
}
}
export class OIDCProvider extends BaseAuthProvider {
private client: Client
private issuer: Issuer
readonly metadata: AuthProviderMetadata = {
id: 'oidc',
name: 'OpenID Connect',
type: 'enterprise',
icon: 'key',
description: 'Sign in with OpenID Connect',
requiresBackend: true,
supportedFeatures: {
signIn: true,
signUp: true,
signOut: true,
tokenRefresh: true,
passwordReset: false,
emailVerification: false,
phoneVerification: false,
mfa: false,
linkAccount: true,
},
}
async initialize(config: OIDCConfig): Promise<void> {
await super.initialize(config)
// Discover OIDC configuration
this.issuer = await Issuer.discover(config.issuerUrl)
this.client = new this.issuer.Client({
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uris: [this.getCallbackUrl()],
response_types: ['code'],
})
}
async getAuthorizationUrl(): Promise<{ url: string; state: OAuthState }> {
const state = generators.state()
const nonce = generators.nonce()
const codeVerifier = generators.codeVerifier()
const codeChallenge = generators.codeChallenge(codeVerifier)
const url = this.client.authorizationUrl({
scope: (this.config as OIDCConfig).scopes?.join(' ') || 'openid email profile',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return {
url,
state: {
state,
nonce,
codeVerifier,
redirectUri: this.getCallbackUrl(),
timestamp: Date.now(),
},
}
}
async handleCallback(params: URLSearchParams): Promise<AuthResult> {
const storedState = await this.getStoredState(params.get('state'))
try {
const tokenSet = await this.client.callback(
this.getCallbackUrl(),
Object.fromEntries(params),
{
state: storedState.state,
nonce: storedState.nonce,
code_verifier: storedState.codeVerifier,
}
)
const userinfo = await this.client.userinfo(tokenSet.access_token)
// JIT Provisioning
const user = await this.jitProvision(userinfo)
return this.createSuccessResult(user, tokenSet.access_token, tokenSet.refresh_token)
} catch (error) {
return this.createErrorResult(this.createError('OIDC_ERROR', error.message))
}
}
async refreshToken(refreshToken: string): Promise<AuthResult> {
try {
const tokenSet = await this.client.refresh(refreshToken)
return {
success: true,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
expiresAt: tokenSet.expires_at,
}
} catch (error) {
return this.createErrorResult(this.createError('REFRESH_FAILED', error.message))
}
}
}// src/app/api/admin/sso/route.ts
// GET - List SSO configurations
export async function GET() {
const configs = await getSSO Configurations()
return successResponse({ configs })
}
// POST - Create SSO configuration
export async function POST(request: NextRequest) {
const config = await request.json()
// Validate configuration
const validation = validateSSOConfig(config)
if (!validation.valid) {
return badRequestResponse(validation.errors)
}
// Test connection
const testResult = await testSSOConnection(config)
if (!testResult.success) {
return badRequestResponse(`Connection test failed: ${testResult.error}`)
}
// Save configuration
const savedConfig = await saveSSOConfiguration(config)
return successResponse({
success: true,
config: savedConfig,
spMetadataUrl: `/api/auth/sso/${savedConfig.id}/metadata`,
})
}
// GET - SP Metadata for IdP configuration
// /api/auth/sso/[configId]/metadata
export async function GET(
request: NextRequest,
{ params }: { params: { configId: string } }
) {
const config = await getSSOConfiguration(params.configId)
if (!config) {
return notFoundResponse('SSO configuration not found')
}
const provider = new SAMLProvider()
await provider.initialize(config)
const metadata = await provider.getServiceProviderMetadata()
return new NextResponse(metadata, {
headers: {
'Content-Type': 'application/xml',
},
})
}// src/lib/auth/jit-provisioning.ts
export interface JITProvisioningConfig {
enabled: boolean
defaultRole: 'member' | 'moderator' | 'admin'
roleMapping?: {
// Map IdP groups to nchat roles
[idpGroup: string]: UserRole
}
allowedDomains?: string[]
autoJoinChannels?: string[]
sendWelcomeMessage?: boolean
}
export async function jitProvisionUser(
idpUser: IdPUser,
config: JITProvisioningConfig
): Promise<AuthUser> {
// Check domain restrictions
if (config.allowedDomains?.length) {
const domain = idpUser.email.split('@')[1]
if (!config.allowedDomains.includes(domain)) {
throw new Error(`Domain ${domain} is not allowed`)
}
}
// Determine role from IdP groups
let role = config.defaultRole
if (config.roleMapping && idpUser.groups) {
for (const group of idpUser.groups) {
if (config.roleMapping[group]) {
role = config.roleMapping[group]
break
}
}
}
// Create user
const user = await createNchatUser({
email: idpUser.email,
username: generateUsername(idpUser.email),
displayName: idpUser.displayName || idpUser.email.split('@')[0],
role,
metadata: {
provider: idpUser.provider,
idpGroups: idpUser.groups,
provisionedAt: new Date().toISOString(),
},
})
// Auto-join channels
if (config.autoJoinChannels?.length) {
await joinChannels(user.id, config.autoJoinChannels)
}
// Send welcome message
if (config.sendWelcomeMessage) {
await sendDirectMessage({
from: 'system',
to: user.id,
message: `Welcome to nchat, ${user.displayName}! Your account was automatically created through your organization's SSO.`,
})
}
return user
}// src/config/auth.config.ts
export const authConfig = {
// Only enable dev auth if explicitly set AND in development
useDevAuth:
process.env.NEXT_PUBLIC_USE_DEV_AUTH === 'true' && process.env.NODE_ENV === 'development',
// Production safeguards
isProduction: process.env.NODE_ENV === 'production',
// Backend URLs
authUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:4000',
graphqlUrl: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:8080/v1/graphql',
// Dev mode settings (only used when useDevAuth is true)
devAuth: {
autoLogin: true,
defaultUser: {
/* ... */
},
availableUsers: [
/* ... */
],
},
// Session settings
session: {
cookieName: 'nchat-session',
maxAge: 30 * 24 * 60 * 60, // 30 days
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax' as const,
},
}// src/lib/auth/validate-config.ts
export function validateAuthConfig(): void {
const isProduction = process.env.NODE_ENV === 'production'
if (isProduction) {
// Ensure dev auth is disabled
if (process.env.NEXT_PUBLIC_USE_DEV_AUTH === 'true') {
throw new Error('FATAL: NEXT_PUBLIC_USE_DEV_AUTH cannot be true in production')
}
// Ensure required secrets are set
const requiredSecrets = ['JWT_SECRET', 'DATABASE_PASSWORD', 'NHOST_ADMIN_SECRET']
for (const secret of requiredSecrets) {
if (!process.env[secret]) {
throw new Error(`FATAL: ${secret} must be set in production`)
}
// Validate minimum length
if (process.env[secret].length < 32) {
throw new Error(`FATAL: ${secret} must be at least 32 characters`)
}
}
}
}// src/services/auth/auth-factory.ts
import { authConfig } from '@/config/auth.config'
import { FauxAuthService } from './faux-auth.service'
import { NhostAuthService } from './nhost-auth.service'
export function createAuthService(): AuthService {
// Double-check production safeguard
if (process.env.NODE_ENV === 'production' && authConfig.useDevAuth) {
console.error('SECURITY: Dev auth attempted in production, using real auth')
return new NhostAuthService()
}
if (authConfig.useDevAuth) {
console.log('AUTH: Using FauxAuthService (development mode)')
return new FauxAuthService()
}
console.log('AUTH: Using NhostAuthService (production mode)')
return new NhostAuthService()
}
// Singleton instance
export const authService = createAuthService()// src/middleware.ts
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Add security headers
response.headers.set(
'X-Auth-Mode',
process.env.NEXT_PUBLIC_USE_DEV_AUTH === 'true' ? 'development' : 'production'
)
// Block dev auth endpoints in production
if (process.env.NODE_ENV === 'production') {
if (request.nextUrl.pathname.startsWith('/api/auth/dev/')) {
return new NextResponse('Not Found', { status: 404 })
}
}
return response
}// src/lib/nhost.ts
import { NhostClient } from '@nhost/nextjs'
export const nhost = new NhostClient({
// Self-hosted nhost URLs (via nself CLI)
authUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://auth.localhost',
graphqlUrl: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://hasura.localhost/v1/graphql',
storageUrl: process.env.NEXT_PUBLIC_STORAGE_URL || 'http://storage.localhost',
// Security settings
refreshIntervalTime: 600, // Refresh tokens every 10 minutes
autoRefreshToken: true,
autoSignIn: false,
// Development settings
devTools: process.env.NODE_ENV === 'development',
})JWT tokens are issued by Nhost Auth and consumed by Hasura for authorization.
JWT Claims Structure:
{
"sub": "user-uuid",
"iat": 1706976000,
"exp": 1707062400,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["user", "member", "admin"],
"x-hasura-default-role": "member",
"x-hasura-user-id": "user-uuid"
}
}Hasura JWT Configuration (.backend/config.yml):
hasura:
jwt:
type: 'HS256'
key: '${HASURA_GRAPHQL_JWT_SECRET}'
claims_namespace: 'https://hasura.io/jwt/claims'// src/lib/auth/session-manager.ts
export interface Session {
id: string
userId: string
accessToken: string
refreshToken: string
device: string
browser: string
os: string
ipAddress: string
location?: {
city: string
country: string
}
isCurrent: boolean
createdAt: Date
lastActiveAt: Date
expiresAt: Date
}
export class SessionManager {
private readonly cookieOptions: CookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
}
async createSession(params: CreateSessionParams): Promise<Session> {
const { userId, accessToken, refreshToken, rememberMe, deviceInfo, ipAddress } = params
const session: Session = {
id: crypto.randomUUID(),
userId,
accessToken,
refreshToken,
device: deviceInfo.device,
browser: deviceInfo.browser,
os: deviceInfo.os,
ipAddress,
location: await this.getLocation(ipAddress),
isCurrent: true,
createdAt: new Date(),
lastActiveAt: new Date(),
expiresAt: rememberMe
? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}
// Store in database
await this.saveSession(session)
return session
}
async refreshSession(refreshToken: string): Promise<Session | null> {
// Verify refresh token
const session = await this.getSessionByRefreshToken(refreshToken)
if (!session || session.expiresAt < new Date()) {
return null
}
// Get new tokens from Nhost
const { session: nhostSession, error } = await nhost.auth.refreshSession()
if (error || !nhostSession) {
return null
}
// Update session
session.accessToken = nhostSession.accessToken
session.refreshToken = nhostSession.refreshToken
session.lastActiveAt = new Date()
await this.updateSession(session)
return session
}
async revokeSession(sessionId: string): Promise<void> {
await this.deleteSession(sessionId)
}
async revokeAllSessions(userId: string, exceptSessionId?: string): Promise<number> {
return await this.deleteUserSessions(userId, exceptSessionId)
}
validateSession(session: Session): { valid: boolean; reason?: string } {
if (session.expiresAt < new Date()) {
return { valid: false, reason: 'Session expired' }
}
// Check if session was revoked (not in DB)
// This is handled by the caller
return { valid: true }
}
detectSuspiciousActivity(
newSession: Session,
previousSessions: Session[]
): SuspiciousActivityResult | null {
// Check for new device
const knownDevices = new Set(previousSessions.map((s) => s.device))
const newDevice = !knownDevices.has(newSession.device)
// Check for new location
const knownCountries = new Set(previousSessions.map((s) => s.location?.country))
const newCountry =
newSession.location?.country && !knownCountries.has(newSession.location.country)
// Check for impossible travel (multiple countries in short time)
const recentSession = previousSessions[0]
const impossibleTravel =
recentSession &&
recentSession.location?.country !== newSession.location?.country &&
newSession.createdAt.getTime() - recentSession.lastActiveAt.getTime() < 60 * 60 * 1000 // 1 hour
if (impossibleTravel) {
return {
type: 'impossible_travel',
severity: 'high',
message: `Login from ${newSession.location?.country} shortly after activity from ${recentSession.location?.country}`,
}
}
if (newDevice && newCountry) {
return {
type: 'new_device_location',
severity: 'medium',
message: `New device from ${newSession.location?.country}`,
}
}
if (newDevice) {
return {
type: 'new_device',
severity: 'low',
message: `New device: ${newSession.device}`,
}
}
return null
}
}
export const sessionManager = new SessionManager()OAuth providers are configured in Nhost Auth. The following environment variables must be set:
# Google
NHOST_AUTH_GOOGLE_ENABLED=true
NHOST_AUTH_GOOGLE_CLIENT_ID=your-client-id
NHOST_AUTH_GOOGLE_CLIENT_SECRET=your-client-secret
# GitHub
NHOST_AUTH_GITHUB_ENABLED=true
NHOST_AUTH_GITHUB_CLIENT_ID=your-client-id
NHOST_AUTH_GITHUB_CLIENT_SECRET=your-client-secret
# Microsoft / Azure AD
NHOST_AUTH_AZUREAD_ENABLED=true
NHOST_AUTH_AZUREAD_TENANT=your-tenant-id
NHOST_AUTH_AZUREAD_CLIENT_ID=your-client-id
NHOST_AUTH_AZUREAD_CLIENT_SECRET=your-client-secret
# Apple
NHOST_AUTH_APPLE_ENABLED=true
NHOST_AUTH_APPLE_CLIENT_ID=your-service-id
NHOST_AUTH_APPLE_TEAM_ID=your-team-id
NHOST_AUTH_APPLE_KEY_ID=your-key-id
NHOST_AUTH_APPLE_PRIVATE_KEY=your-private-key
# Discord
NHOST_AUTH_DISCORD_ENABLED=true
NHOST_AUTH_DISCORD_CLIENT_ID=your-client-id
NHOST_AUTH_DISCORD_CLIENT_SECRET=your-client-secretGoal: Complete email/password authentication with production-ready security
Tasks:
- Complete
/api/auth/signupendpoint - Add
/api/auth/signoutendpoint - Add
/api/auth/forgot-passwordendpoint - Add
/api/auth/reset-passwordendpoint - Add
/api/auth/verify-emailendpoint - Implement email sending service (SendGrid/Mailgun)
- Add password strength validation
- Implement account lockout after failed attempts
- Add auth event logging
Deliverables:
- Full email/password auth flow
- Password reset functionality
- Email verification
- Rate limiting on all endpoints
Goal: Enable social login with major providers
Tasks:
- Complete Google OAuth integration
- Complete GitHub OAuth integration
- Add Microsoft/Azure AD OAuth
- Add Apple Sign In
- Add Discord OAuth
- Implement OAuth callback handler
- Add account linking (connect OAuth to existing account)
- Handle OAuth errors gracefully
Deliverables:
- 5+ OAuth providers working
- Account linking functionality
- Unified OAuth callback handling
Goal: Robust 2FA implementation
Tasks:
- Complete 2FA setup flow
- Complete 2FA verification flow
- Implement backup codes (done)
- Implement trusted devices
- Add 2FA disable flow
- Add SMS fallback (optional)
- Add 2FA enforcement (admin setting)
- UI components for 2FA setup
Deliverables:
- Full 2FA with TOTP
- Backup code recovery
- Trusted device management
- Optional SMS fallback
Goal: Enterprise-grade SSO support
Tasks:
- Implement SAML 2.0 provider
- Implement OIDC provider
- Add SSO configuration admin UI
- Implement JIT provisioning
- Add role mapping from IdP groups
- Support major IdPs (Okta, Azure AD, etc.)
- Generate SP metadata
- Test with multiple IdPs
Deliverables:
- SAML 2.0 support
- OIDC support
- Admin SSO configuration
- JIT provisioning
Goal: Complete session management and security hardening
Tasks:
- Implement session listing UI
- Add session revocation
- Implement "sign out everywhere"
- Add suspicious activity detection
- Implement IP/geo blocking
- Add security event notifications
- Implement audit logging
- Security penetration testing
Deliverables:
- Full session management
- Security monitoring
- Audit trail
- Security documentation
Goal: Production-ready with full test coverage
Tasks:
- Unit tests for all auth services
- Integration tests for API endpoints
- E2E tests for auth flows
- Load testing
- Security testing
- API documentation
- Admin documentation
- User documentation
Deliverables:
- 80%+ test coverage
- Complete API docs
- Admin guides
- User guides
- Hashing: bcrypt with cost factor 10-12
- Minimum Length: 8 characters (configurable)
- Complexity: Configurable requirements (upper, lower, number, symbol)
- History: Prevent reusing last 5 passwords
- Expiry: Configurable password expiration
- JWT Secret: Minimum 256 bits (32 characters)
- Access Token: Short-lived (15-60 minutes)
- Refresh Token: Long-lived (7-30 days), stored in httpOnly cookie
- Token Rotation: New refresh token on each refresh
- CSRF Protection: SameSite cookies, CSRF tokens for state-changing operations
- Session Fixation: Regenerate session ID after authentication
- Concurrent Sessions: Configurable limit (default: unlimited)
- Idle Timeout: Configurable (default: 30 minutes)
| Endpoint | Limit | Window |
|---|---|---|
| /api/auth/signin | 5 | 15 minutes |
| /api/auth/signup | 3 | 1 hour |
| /api/auth/forgot-password | 3 | 15 minutes |
| /api/auth/2fa/verify | 5 | 5 minutes |
| /api/auth/oauth/* | 10 | 1 minute |
All authentication events should be logged:
interface AuthAuditEvent {
id: string
type: AuthEventType
userId?: string
email?: string
ipAddress: string
userAgent: string
success: boolean
errorCode?: string
metadata?: Record<string, unknown>
timestamp: Date
}
type AuthEventType =
| 'SIGNUP'
| 'SIGNIN'
| 'SIGNOUT'
| 'PASSWORD_RESET_REQUESTED'
| 'PASSWORD_RESET_COMPLETED'
| 'PASSWORD_CHANGED'
| 'EMAIL_VERIFIED'
| '2FA_ENABLED'
| '2FA_DISABLED'
| '2FA_VERIFIED'
| '2FA_FAILED'
| 'BACKUP_CODE_USED'
| 'DEVICE_TRUSTED'
| 'SESSION_REVOKED'
| 'OAUTH_SIGNIN'
| 'SSO_SIGNIN'
| 'ACCOUNT_LOCKED'
| 'SUSPICIOUS_ACTIVITY'// src/services/auth/__tests__/email-password.provider.test.ts
describe('EmailPasswordProvider', () => {
describe('signIn', () => {
it('should sign in with valid credentials', async () => {
const result = await provider.signIn({
email: '[email protected]',
password: 'ValidP@ss123',
})
expect(result.success).toBe(true)
expect(result.user).toBeDefined()
expect(result.accessToken).toBeDefined()
})
it('should fail with invalid password', async () => {
const result = await provider.signIn({
email: '[email protected]',
password: 'wrong',
})
expect(result.success).toBe(false)
expect(result.error.code).toBe('AUTH_FAILED')
})
it('should require 2FA when enabled', async () => {
// Enable 2FA for test user
await enable2FA(testUser.id, testSecret)
const result = await provider.signIn({
email: testUser.email,
password: 'ValidP@ss123',
})
expect(result.success).toBe(true)
expect(result.requires2FA).toBe(true)
expect(result.tempToken).toBeDefined()
})
})
describe('signUp', () => {
it('should create new user', async () => {
const result = await provider.signUp(
{
email: '[email protected]',
password: 'ValidP@ss123',
},
{
username: 'newuser',
displayName: 'New User',
}
)
expect(result.success).toBe(true)
expect(result.user.email).toBe('[email protected]')
})
it('should fail with weak password', async () => {
const result = await provider.signUp({
email: '[email protected]',
password: '123',
})
expect(result.success).toBe(false)
expect(result.error.code).toBe('WEAK_PASSWORD')
})
})
})// src/app/api/auth/__tests__/signin.integration.test.ts
describe('POST /api/auth/signin', () => {
it('should return tokens on successful login', async () => {
const response = await fetch('/api/auth/signin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: '[email protected]',
password: 'ValidP@ss123',
}),
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data.accessToken).toBeDefined()
expect(data.user).toBeDefined()
})
it('should rate limit after 5 attempts', async () => {
// Make 5 failed attempts
for (let i = 0; i < 5; i++) {
await fetch('/api/auth/signin', {
method: 'POST',
body: JSON.stringify({
email: '[email protected]',
password: 'wrong',
}),
})
}
// 6th attempt should be rate limited
const response = await fetch('/api/auth/signin', {
method: 'POST',
body: JSON.stringify({
email: '[email protected]',
password: 'wrong',
}),
})
expect(response.status).toBe(429)
})
})// e2e/auth/signin.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Sign In Flow', () => {
test('should complete full sign in flow', async ({ page }) => {
await page.goto('/auth/signin')
await page.fill('[name="email"]', '[email protected]')
await page.fill('[name="password"]', 'ValidP@ss123')
await page.click('button[type="submit"]')
// Should redirect to chat
await expect(page).toHaveURL('/chat')
// Should show user menu
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
})
test('should require 2FA when enabled', async ({ page }) => {
await page.goto('/auth/signin')
await page.fill('[name="email"]', '[email protected]')
await page.fill('[name="password"]', 'ValidP@ss123')
await page.click('button[type="submit"]')
// Should show 2FA form
await expect(page.locator('[data-testid="2fa-form"]')).toBeVisible()
// Enter TOTP code
await page.fill('[name="totp"]', '123456')
await page.click('button[type="submit"]')
// Should redirect to chat
await expect(page).toHaveURL('/chat')
})
test('should handle OAuth flow', async ({ page }) => {
await page.goto('/auth/signin')
await page.click('[data-testid="google-signin"]')
// Wait for Google OAuth (mocked in test)
await expect(page).toHaveURL('/chat')
})
})-
Update Environment Variables:
# Remove or set to false NEXT_PUBLIC_USE_DEV_AUTH=false # Add production credentials NEXT_PUBLIC_AUTH_URL=https://auth.yourdomain.com JWT_SECRET=your-secure-256-bit-secret DATABASE_PASSWORD=your-secure-password
-
Run Database Migrations:
cd .backend nself migrate up -
Create Initial Admin User:
-- First user becomes owner INSERT INTO auth.users (email, encrypted_password, email_verified) VALUES ('[email protected]', '$2a$10$...', true);
-
Configure OAuth Providers:
- Set up apps in provider developer consoles
- Add client IDs/secrets to environment
- Configure redirect URIs
-
Enable SSL/TLS:
- Ensure all auth endpoints use HTTPS
- Configure secure cookies
- Create provider class extending
BaseAuthProvider - Implement required methods
- Register in
providers/index.ts - Add to AppConfig interface
- Add UI button component
- Configure in Nhost (if using)
-
Configure SAML/OIDC Provider:
- Get metadata from IdP
- Configure SP in admin panel
- Share SP metadata with IdP
-
Set Up Role Mapping:
{ roleMapping: { 'admin-group': 'admin', 'moderator-group': 'moderator', 'users': 'member', } }
-
Enable JIT Provisioning:
- Configure default role
- Set allowed domains
- Configure auto-join channels
-
Test with IdP:
- Initiate SSO flow
- Verify user creation
- Verify role assignment
# Auth Mode
NEXT_PUBLIC_USE_DEV_AUTH=false
# Nhost Configuration
NEXT_PUBLIC_AUTH_URL=http://auth.localhost
NEXT_PUBLIC_GRAPHQL_URL=http://hasura.localhost/v1/graphql
NEXT_PUBLIC_STORAGE_URL=http://storage.localhost
NHOST_ADMIN_SECRET=your-admin-secret
# JWT
JWT_SECRET=minimum-32-character-secret-key-here
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=nself
DATABASE_USER=postgres
DATABASE_PASSWORD=your-secure-password
# OAuth - Google
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
# OAuth - GitHub
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# OAuth - Microsoft
MICROSOFT_CLIENT_ID=your-client-id
MICROSOFT_CLIENT_SECRET=your-client-secret
MICROSOFT_TENANT_ID=your-tenant-id
# OAuth - Apple
APPLE_CLIENT_ID=your-service-id
APPLE_TEAM_ID=your-team-id
APPLE_KEY_ID=your-key-id
APPLE_PRIVATE_KEY=your-private-key
# OAuth - Discord
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret
# ID.me
IDME_CLIENT_ID=your-client-id
IDME_CLIENT_SECRET=your-client-secret
IDME_SANDBOX=true
# SMS (Twilio)
TWILIO_ACCOUNT_SID=your-account-sid
TWILIO_AUTH_TOKEN=your-auth-token
TWILIO_PHONE_NUMBER=+1234567890
# Email (SendGrid)
SENDGRID_API_KEY=your-api-key
[email protected]CREATE TABLE auth.users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
encrypted_password TEXT,
email_verified BOOLEAN DEFAULT false,
phone_number TEXT,
phone_verified BOOLEAN DEFAULT false,
given_name TEXT,
family_name TEXT,
display_name TEXT,
avatar_url TEXT,
locale TEXT,
timezone TEXT,
metadata JSONB,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);CREATE TABLE nchat.nchat_users (
id UUID PRIMARY KEY,
auth_user_id UUID REFERENCES auth.users(id),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
avatar_url TEXT,
bio TEXT,
status TEXT DEFAULT 'offline',
role TEXT DEFAULT 'member',
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);CREATE TABLE nchat.nchat_user_sessions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES nchat.nchat_users(id),
device TEXT,
browser TEXT,
os TEXT,
ip_address INET,
location JSONB,
is_current BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
last_active_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);CREATE TABLE nchat.nchat_user_2fa_settings (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
secret TEXT NOT NULL,
is_enabled BOOLEAN DEFAULT false,
enabled_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);CREATE TABLE nchat.nchat_user_backup_codes (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);CREATE TABLE nchat.nchat_sso_configurations (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
provider TEXT NOT NULL, -- 'saml' | 'oidc'
config JSONB NOT NULL,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);This implementation plan addresses the following tasks from TODO.md:
- Task 86: Implement password reset flow
- Task 87: Implement email verification
- Task 88: Complete OAuth provider integrations
- Task 89: Implement 2FA/MFA
- Task 90: Implement enterprise SSO (SAML/OIDC)
- Task 91: Session management and security
Document End
This plan was created based on analysis of the existing nChat codebase and represents a comprehensive approach to implementing production-ready authentication.