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/authInitiates 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)
Consent Endpoints
GET /api/oauth2/consent?request_id=<id>
POST /api/oauth2/consentProfessional HTML consent page with security warnings and scope descriptions.
Token Endpoint
POST /api/oauth2/tokenExchanges authorization code for access token or refreshes tokens.
Grant Types:
authorization_code- Exchange code for tokensrefresh_token- Refresh access token
OAuth2 Scopes
Available scopes for fine-grained access control:
mcp:read- Read MCP server installations and configurationsaccount:read- Read account informationuser:read- Read user profile informationteams:read- Read team memberships and informationoffline_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 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.