Development Docs

OAuth Implementation Guide

This guide explains how to implement OAuth providers and the OAuth2 server in DeployStack's backend. The system supports both OAuth provider integration (GitHub, Google, etc.) and OAuth2 server functionality for API access.

Architecture Overview

DeployStack implements two distinct OAuth systems:

1. OAuth Provider Integration (Social Login)

For user authentication via third-party providers:

  • Arctic - OAuth 2.0 client library for various providers
  • Lucia - Authentication library for session management
  • Global Settings - Database-driven configuration for OAuth providers

2. OAuth2 Server (API Access)

For programmatic API access by CLI tools and applications:

  • RFC 6749 compliant OAuth2 authorization server
  • PKCE support (RFC 7636) for enhanced security
  • Scope-based access control for fine-grained permissions
  • Dual authentication supporting both cookies and Bearer tokens

OAuth2 Server Implementation

The OAuth2 server enables CLI tools and applications to access DeployStack APIs securely using Bearer tokens.

For OAuth2 security details including PKCE, token security, authorization flow security, and Bearer token authentication, see the Security Policy.

Database Schema

Three tables support OAuth2 functionality:

-- Authorization codes (short-lived, exchanged for tokens)
CREATE TABLE oauth_authorization_codes (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  client_id TEXT NOT NULL,
  redirect_uri TEXT NOT NULL,
  scope TEXT NOT NULL,
  state TEXT NOT NULL,
  code_challenge TEXT NOT NULL,
  code_challenge_method TEXT NOT NULL,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES authUser(id)
);

-- Access tokens (1-hour lifetime)
CREATE TABLE oauth_access_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  client_id TEXT NOT NULL,
  scope TEXT NOT NULL,
  token_hash TEXT NOT NULL,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES authUser(id)
);

-- Refresh tokens (30-day lifetime)
CREATE TABLE oauth_refresh_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  client_id TEXT NOT NULL,
  token_hash TEXT NOT NULL,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES authUser(id)
);

OAuth2 Services

AuthorizationService

Handles OAuth2 authorization flow with PKCE validation:

// services/backend/src/services/oauth/authorizationService.ts
export class AuthorizationService {
  static validateClient(clientId: string): boolean
  static validateRedirectUri(redirectUri: string): boolean
  static validateScope(scope: string): boolean
  static async storeAuthorizationRequest(...)
  static async getAuthorizationRequest(requestId: string)
  static async generateAuthorizationCode(requestId: string)
  static async verifyAuthorizationCode(code: string, codeVerifier: string, ...)
}

TokenService

Manages access and refresh tokens:

// services/backend/src/services/oauth/tokenService.ts
export class TokenService {
  static async generateAccessToken(userId: string, scope: string, clientId: string)
  static async generateRefreshToken(userId: string, clientId: string)
  static async verifyAccessToken(token: string)
  static async refreshAccessToken(refreshToken: string, clientId: string)
  static async revokeToken(tokenId: string)
}

OAuthCleanupService

Automatic cleanup of expired tokens (runs every hour):

// services/backend/src/services/oauth/cleanupService.ts
export class OAuthCleanupService {
  static startCleanupScheduler(): void
  static async cleanupExpiredTokens(): Promise<void>
}

OAuth2 Endpoints

Authorization Endpoint

GET /api/oauth2/auth

Initiates OAuth2 flow with PKCE. Redirects to consent page for user authorization.

Parameters:

  • response_type=code (required)
  • client_id (required)
  • redirect_uri (required)
  • scope (required)
  • state (required)
  • code_challenge (required)
  • code_challenge_method=S256 (required)
GET /api/oauth2/consent?request_id=<id>
POST /api/oauth2/consent

Professional HTML consent page with security warnings and scope descriptions.

Token Endpoint

POST /api/oauth2/token

Exchanges authorization code for access token or refreshes tokens.

Grant Types:

  • authorization_code - Exchange code for tokens
  • refresh_token - Refresh access token

OAuth2 Scopes

Available scopes for fine-grained access control:

  • mcp:read - Read MCP server installations and configurations
  • account:read - Read account information
  • user:read - Read user profile information
  • teams:read - Read team memberships and information
  • offline_access - Maintain access when not actively using the application

Dual Authentication Middleware

Enable both cookie and OAuth2 authentication on endpoints:

import { requireAuthenticationAny, requireOAuthScope } from '../../middleware/oauthMiddleware';

fastify.get('/your-endpoint', {
  schema: {
    security: [
      { cookieAuth: [] },    // Cookie authentication
      { bearerAuth: [] }     // OAuth2 Bearer token
    ]
  },
  preValidation: [
    requireAuthenticationAny(),     // Accept either auth method
    requireOAuthScope('your:scope') // Enforce OAuth2 scope
  ]
}, async (request, reply) => {
  // Endpoint accessible via both authentication methods
  const authType = request.tokenPayload ? 'oauth2' : 'cookie';
  const userId = request.user!.id;
});

Client Configuration

DeployStack Gateway CLI:

  • Client ID: deploystack-gateway-cli
  • Redirect URIs: http://localhost:8976/oauth/callback, http://127.0.0.1:8976/oauth/callback
  • PKCE: Required (SHA256 method)
  • Token Lifetime: 1 hour access tokens, 30 day refresh tokens

OAuth2 Flow Example

# 1. Authorization Request (redirects to consent page)
curl -b cookies.txt "http://localhost:3000/api/oauth2/auth?response_type=code&client_id=deploystack-gateway-cli&redirect_uri=http://localhost:8976/oauth/callback&scope=mcp:read%20teams:read&state=xyz&code_challenge=abc&code_challenge_method=S256"

# 2. User approves on consent page, receives authorization code

# 3. Token Exchange
curl -X POST "http://localhost:3000/api/oauth2/token" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "auth_code_here",
    "redirect_uri": "http://localhost:8976/oauth/callback",
    "client_id": "deploystack-gateway-cli",
    "code_verifier": "verifier_here"
  }'

# 4. API Access with Bearer Token
curl -H "Authorization: Bearer <access_token>" \
     "http://localhost:3000/api/teams/me/default"

OAuth Provider Integration (Social Login)

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