DeployStack Docs

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 setup

Adding 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/providers

2. 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:generate

Provider-Specific Considerations

Google OAuth

  • Scopes: Use openid email profile for 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 profile or User.Read
  • User Info Endpoint: https://graph.microsoft.com/v1.0/me
  • Email: Available as mail or userPrincipalName

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

  1. State Parameter: Always validate the state parameter to prevent CSRF attacks
  2. Secure Cookies: Use secure, httpOnly cookies for state storage
  3. HTTPS: Always use HTTPS in production
  4. Scope Minimization: Request only the scopes you actually need

Error Handling

  1. Graceful Degradation: Handle cases where email is not available
  2. User Feedback: Provide clear error messages for common issues
  3. Logging: Log errors for debugging but don't expose sensitive information

Database Design

  1. Provider IDs: Store provider-specific user IDs for account linking
  2. Email Verification: Mark OAuth emails as verified by default
  3. Account Linking: Allow users to link multiple OAuth providers

Testing

  1. Mock Providers: Use mock OAuth providers for testing
  2. State Validation: Test state parameter validation
  3. 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