OAuth Implementation Guide
This guide explains how to implement OAuth providers in DeployStack's backend. The system is designed to support multiple OAuth providers with a consistent pattern.
Architecture Overview
DeployStack uses the following libraries for OAuth implementation:
- Arctic - OAuth 2.0 client library for various providers
- Lucia - Authentication library for session management
- Global Settings - Database-driven configuration for OAuth providers
Current Implementation: GitHub OAuth
The GitHub OAuth implementation serves as a reference for adding other providers.
File Structure
services/backend/src/
├── routes/auth/
│ ├── github.ts # GitHub OAuth routes
│ ├── githubStatus.ts # GitHub OAuth status endpoint
│ └── schemas.ts # OAuth validation schemas
├── global-settings/
│ └── github-oauth.ts # GitHub OAuth global settings
└── lib/
└── lucia.ts # Lucia authentication setupAdding a New OAuth Provider
Follow these steps to add a new OAuth provider (e.g., Google):
1. Install Provider Support
First, ensure Arctic supports your provider:
# Arctic supports many providers out of the box
# Check: https://arctic.js.org/providers2. Create Global Settings
Create a new global settings file for your provider:
// services/backend/src/global-settings/google-oauth.ts
import { z } from 'zod';
import type { GlobalSettingDefinition } from './types';
export const GoogleOAuthSettingsSchema = z.object({
enabled: z.boolean().default(false),
clientId: z.string().min(1, 'Client ID is required'),
clientSecret: z.string().min(1, 'Client Secret is required'),
callbackUrl: z.string().url('Must be a valid URL'),
scope: z.string().default('openid email profile'),
});
export type GoogleOAuthSettings = z.infer<typeof GoogleOAuthSettingsSchema>;
export const googleOAuthSettings: GlobalSettingDefinition[] = [
{
key: 'google_oauth_enabled',
type: 'boolean',
defaultValue: 'false',
description: 'Enable Google OAuth authentication',
group_id: 'auth',
},
{
key: 'google_oauth_client_id',
type: 'string',
defaultValue: '',
description: 'Google OAuth Client ID',
group_id: 'auth',
},
{
key: 'google_oauth_client_secret',
type: 'string',
defaultValue: '',
description: 'Google OAuth Client Secret',
group_id: 'auth',
is_encrypted: true,
},
{
key: 'google_oauth_callback_url',
type: 'string',
defaultValue: 'http://localhost:3000/api/auth/google/callback',
description: 'Google OAuth callback URL',
group_id: 'auth',
},
{
key: 'google_oauth_scope',
type: 'string',
defaultValue: 'openid email profile',
description: 'Google OAuth scopes (comma-separated)',
group_id: 'auth',
},
];3. Add Provider to Global Settings Index
Update the global settings index:
// services/backend/src/global-settings/index.ts
import { googleOAuthSettings } from './google-oauth';
// Add to the settings array
export const allGlobalSettings = [
...existingSettings,
...googleOAuthSettings,
];
// Add helper function
export async function getGoogleOAuthConfiguration(): Promise<GoogleOAuthSettings | null> {
const enabled = await getSetting('google_oauth_enabled');
if (enabled !== 'true') return null;
const clientId = await getSetting('google_oauth_client_id');
const clientSecret = await getSetting('google_oauth_client_secret');
const callbackUrl = await getSetting('google_oauth_callback_url');
const scope = await getSetting('google_oauth_scope');
if (!clientId || !clientSecret) return null;
return GoogleOAuthSettingsSchema.parse({
enabled: true,
clientId,
clientSecret,
callbackUrl,
scope,
});
}4. Create OAuth Routes
Create the OAuth routes file:
// services/backend/src/routes/auth/google.ts
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { createSchema } from 'zod-openapi';
import { getLucia } from '../../lib/lucia';
import { getDb, getSchema } from '../../db';
import { eq } from 'drizzle-orm';
import { generateId } from 'lucia';
import { generateState } from 'arctic';
import { GlobalSettingsInitService } from '../../global-settings';
// Define callback schema
const GoogleCallbackSchema = z.object({
code: z.string(),
state: z.string(),
});
type GoogleCallbackInput = z.infer<typeof GoogleCallbackSchema>;
export default async function googleAuthRoutes(fastify: FastifyInstance) {
// Route to initiate Google login
fastify.get('/login', async (_request, reply: FastifyReply) => {
// Check if login is enabled
const isLoginEnabled = await GlobalSettingsInitService.isLoginEnabled();
if (!isLoginEnabled) {
return reply.status(403).send({
error: 'Login is currently disabled by administrator.'
});
}
// Check if Google OAuth is enabled and configured
const googleConfig = await GlobalSettingsInitService.getGoogleOAuthConfiguration();
if (!googleConfig) {
return reply.status(403).send({
error: 'Google OAuth is not enabled or not properly configured.'
});
}
const state = generateState();
// Create Google OAuth instance
const { Google } = await import('arctic');
const googleAuth = new Google(
googleConfig.clientId,
googleConfig.clientSecret,
googleConfig.callbackUrl
);
const scopes = googleConfig.scope.split(',').map(s => s.trim());
const url = await googleAuth.createAuthorizationURL(state, scopes);
// Store state in cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
return reply.redirect(url.toString());
});
// Route to handle Google callback
fastify.get<{ Querystring: GoogleCallbackInput }>('/callback', async (request, reply: FastifyReply) => {
// Validate state parameter
const storedState = request.cookies?.oauth_state;
const { code, state } = request.query;
if (!storedState || !state || storedState !== state) {
return reply.status(400).send({ error: 'Invalid OAuth state.' });
}
// Clear state cookie
reply.setCookie('oauth_state', '', { maxAge: -1, path: '/' });
try {
const googleConfig = await GlobalSettingsInitService.getGoogleOAuthConfiguration();
if (!googleConfig) {
return reply.status(403).send({ error: 'Google OAuth not configured.' });
}
// Create Google OAuth instance
const { Google } = await import('arctic');
const googleAuth = new Google(
googleConfig.clientId,
googleConfig.clientSecret,
googleConfig.callbackUrl
);
// Exchange code for tokens
const tokens = await googleAuth.validateAuthorizationCode(code);
// Fetch user information
const googleUserResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.accessToken()}`
}
});
if (!googleUserResponse.ok) {
return reply.status(400).send({ error: 'Failed to fetch Google user information.' });
}
const googleUser = await googleUserResponse.json();
// Extract user email
const userEmail = googleUser.email;
if (!userEmail) {
return reply.status(400).send({ error: 'Google email not available.' });
}
// Get database and schema
const db = getDb();
const schema = getSchema();
const authUserTable = schema.authUser;
// Check if user already exists with this Google ID
const existingUser = await (db as any)
.select()
.from(authUserTable)
.where(eq(authUserTable.google_id, googleUser.id.toString()))
.limit(1);
if (existingUser.length > 0) {
// Existing user - create session
const userId = existingUser[0].id;
const sessionId = generateId(40);
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
const authSessionTable = schema.authSession;
await (db as any).insert(authSessionTable).values({
id: sessionId,
user_id: userId,
expires_at: expiresAt.getTime()
});
const sessionCookie = getLucia().createSessionCookie(sessionId);
reply.setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
const frontendUrl = await GlobalSettingsInitService.getPageUrl();
return reply.redirect(frontendUrl);
}
// Check for existing user by email
const userWithSameEmail = await (db as any)
.select()
.from(authUserTable)
.where(eq(authUserTable.email, userEmail.toLowerCase()))
.limit(1);
if (userWithSameEmail.length > 0) {
// Link Google account to existing user
const existingUserId = userWithSameEmail[0].id;
await (db as any)
.update(authUserTable)
.set({ google_id: googleUser.id.toString() })
.where(eq(authUserTable.id, existingUserId));
// Create session
const session = await getLucia().createSession(existingUserId, {});
const sessionCookie = getLucia().createSessionCookie(session.id);
reply.setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
const frontendUrl = await GlobalSettingsInitService.getPageUrl();
return reply.redirect(frontendUrl);
}
// Prevent first user creation via OAuth
const allUsers = await (db as any).select().from(authUserTable).limit(1);
if (allUsers.length === 0) {
return reply.status(403).send({
error: 'The first user must be created via email registration.'
});
}
// Create new user
const newUserId = generateId(15);
const newUserData = {
id: newUserId,
username: googleUser.email.split('@')[0] || `google_user_${newUserId}`,
email: userEmail.toLowerCase(),
auth_type: 'google',
first_name: googleUser.given_name || null,
last_name: googleUser.family_name || null,
google_id: googleUser.id.toString(),
role_id: 'global_user',
email_verified: true,
};
await (db as any).insert(authUserTable).values(newUserData);
// Create default team
try {
const { TeamService } = await import('../../services/teamService');
await TeamService.createDefaultTeamForUser(newUserId, newUserData.username);
} catch (teamError) {
// Don't fail login if team creation fails
}
// Create session
const sessionId = generateId(40);
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
const authSessionTable = schema.authSession;
await (db as any).insert(authSessionTable).values({
id: sessionId,
user_id: newUserId,
expires_at: expiresAt.getTime()
});
const sessionCookie = getLucia().createSessionCookie(sessionId);
reply.setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
const frontendUrl = await GlobalSettingsInitService.getPageUrl();
return reply.redirect(frontendUrl);
} catch (error) {
fastify.log.error(error, 'Error during Google OAuth callback:');
return reply.status(500).send({ error: 'An unexpected error occurred during Google login.' });
}
});
}5. Create Status Endpoint
Create a status endpoint for the provider:
// services/backend/src/routes/auth/googleStatus.ts
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createSchema } from 'zod-openapi';
import { GlobalSettingsInitService } from '../../global-settings';
const GoogleStatusResponseSchema = z.object({
enabled: z.boolean(),
configured: z.boolean(),
callbackUrl: z.string().optional(),
});
export default async function googleStatusRoutes(fastify: FastifyInstance) {
fastify.get('/status', {
schema: {
tags: ['Authentication'],
summary: 'Get Google OAuth status',
description: 'Returns the current status and configuration of Google OAuth',
response: {
200: createSchema(GoogleStatusResponseSchema)
}
}
}, async (_request, reply) => {
const googleConfig = await GlobalSettingsInitService.getGoogleOAuthConfiguration();
return reply.send({
enabled: googleConfig !== null,
configured: googleConfig !== null && !!googleConfig.clientId && !!googleConfig.clientSecret,
callbackUrl: googleConfig?.callbackUrl,
});
});
}6. Register Routes
Add the new routes to your route registration:
// services/backend/src/routes/auth/index.ts
import googleAuthRoutes from './google';
import googleStatusRoutes from './googleStatus';
export default async function authRoutes(fastify: FastifyInstance) {
// Register Google OAuth routes
await fastify.register(googleAuthRoutes, { prefix: '/google' });
await fastify.register(googleStatusRoutes, { prefix: '/google' });
}7. Update Database Schema
Add the provider-specific field to your user schema:
// services/backend/src/db/schema.sqlite.ts
export const authUser = sqliteTable('authUser', {
// ... existing fields
google_id: text('google_id').unique(),
// ... other fields
});8. Generate Database Migration
Run the migration generation command:
cd services/backend
npm run db:generateProvider-Specific Considerations
Google OAuth
- Scopes: Use
openid email profilefor basic user information - User Info Endpoint:
https://www.googleapis.com/oauth2/v2/userinfo - Email: Always available in the user info response
Microsoft OAuth
- Scopes: Use
openid email profileorUser.Read - User Info Endpoint:
https://graph.microsoft.com/v1.0/me - Email: Available as
mailoruserPrincipalName
Facebook OAuth
- Scopes: Use
email public_profile - User Info Endpoint:
https://graph.facebook.com/me?fields=id,name,email - Email: Requires explicit permission and may not always be available
Best Practices
Security
- State Parameter: Always validate the state parameter to prevent CSRF attacks
- Secure Cookies: Use secure, httpOnly cookies for state storage
- HTTPS: Always use HTTPS in production
- Scope Minimization: Request only the scopes you actually need
Error Handling
- Graceful Degradation: Handle cases where email is not available
- User Feedback: Provide clear error messages for common issues
- Logging: Log errors for debugging but don't expose sensitive information
Database Design
- Provider IDs: Store provider-specific user IDs for account linking
- Email Verification: Mark OAuth emails as verified by default
- Account Linking: Allow users to link multiple OAuth providers
Testing
- Mock Providers: Use mock OAuth providers for testing
- State Validation: Test state parameter validation
- Error Scenarios: Test various error conditions
Common Issues
Email Not Available
Some providers may not provide email addresses. Handle this gracefully:
if (!userEmail) {
return reply.status(400).send({
error: 'Email address is required but not provided by the OAuth provider.'
});
}Account Conflicts
Handle cases where a user tries to link an OAuth account that's already linked:
if (existingUser.length > 0 && existingUser[0].id !== currentUserId) {
return reply.status(409).send({
error: 'This OAuth account is already linked to another user.'
});
}Session Creation Issues
If you encounter session creation issues, use the manual session creation approach as shown in the GitHub implementation.
Resources
Email Integration Documentation
Complete email system with Nodemailer, Pug templates, SMTP configuration, and type-safe helper methods for DeployStack Backend.
DeployStack Plugin System
Comprehensive guide to creating extensible plugins with database tables, isolated API routes, and security features for DeployStack Backend development.