Skip to content

Latest commit

 

History

History
533 lines (421 loc) · 16.2 KB

File metadata and controls

533 lines (421 loc) · 16.2 KB
title OAuth Implementation Guide
description Developer guide for implementing OAuth providers in DeployStack

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