| title | OAuth Implementation Guide |
|---|---|
| description | Developer guide for implementing OAuth providers in DeployStack |
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.
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
The GitHub OAuth implementation serves as a reference for adding other providers.
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 setup
Follow these steps to add a new OAuth provider (e.g., Google):
First, ensure Arctic supports your provider:
# Arctic supports many providers out of the box
# Check: https://arctic.js.org/providersCreate 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',
},
];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,
});
}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.' });
}
});
}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,
});
});
}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' });
}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
});Run the migration generation command:
cd services/backend
npm run db:generate- 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
- Scopes: Use
openid email profileorUser.Read - User Info Endpoint:
https://graph.microsoft.com/v1.0/me - Email: Available as
mailoruserPrincipalName
- 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
- 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
- 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
- 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
- Mock Providers: Use mock OAuth providers for testing
- State Validation: Test state parameter validation
- Error Scenarios: Test various error conditions
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.'
});
}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.'
});
}If you encounter session creation issues, use the manual session creation approach as shown in the GitHub implementation.