/** * External Auth Service for Stack.Gallery Registry * Orchestrates OAuth/OIDC and LDAP authentication flows */ import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts'; import { AuthService, type IAuthResult } from './auth.service.ts'; import { AuditService } from './audit.service.ts'; import { cryptoService } from './crypto.service.ts'; import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts'; import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts'; export interface IOAuthState { providerId: string; returnUrl?: string; nonce: string; exp: number; } export class ExternalAuthService { private strategyFactory: AuthStrategyFactory; private authService: AuthService; private auditService: AuditService; constructor() { this.strategyFactory = new AuthStrategyFactory(cryptoService); this.authService = new AuthService(); this.auditService = new AuditService({ actorType: 'system' }); } /** * Initiate OAuth flow - returns authorization URL and state */ public async initiateOAuth( providerId: string, returnUrl?: string ): Promise<{ authUrl: string; state: string }> { const provider = await AuthProvider.findById(providerId); if (!provider) { throw new Error('Provider not found'); } if (provider.status !== 'active') { throw new Error('Provider is not active'); } if (provider.type !== 'oidc') { throw new Error('Provider is not an OAuth/OIDC provider'); } const strategy = this.strategyFactory.create(provider); if (!strategy.getAuthorizationUrl) { throw new Error('Provider does not support OAuth flow'); } // Generate state with encoded data const state = await this.generateState(providerId, returnUrl); const nonce = crypto.randomUUID(); const authUrl = await strategy.getAuthorizationUrl(state, nonce); return { authUrl, state }; } /** * Handle OAuth callback - exchange code for user and create session */ public async handleOAuthCallback( data: IOAuthCallbackData, options: { ipAddress?: string; userAgent?: string } = {} ): Promise { // Validate state const stateData = await this.validateState(data.state); if (!stateData) { return { success: false, errorCode: 'INVALID_STATE', errorMessage: 'Invalid or expired state', }; } // Get provider const provider = await AuthProvider.findById(stateData.providerId); if (!provider || provider.status !== 'active') { return { success: false, errorCode: 'PROVIDER_INACTIVE', errorMessage: 'Provider not found or inactive', }; } // Handle OAuth callback const strategy = this.strategyFactory.create(provider); if (!strategy.handleCallback) { return { success: false, errorCode: 'INVALID_PROVIDER', errorMessage: 'Provider does not support OAuth callback', }; } let externalUser: IExternalUserInfo; try { externalUser = await strategy.handleCallback(data); } catch (error) { await this.auditService.log('USER_LOGIN', 'user', { success: false, metadata: { providerId: provider.id, providerName: provider.name, error: error instanceof Error ? error.message : String(error), }, }); return { success: false, errorCode: 'PROVIDER_ERROR', errorMessage: error instanceof Error ? error.message : 'Authentication failed', }; } // Find or create user const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options); // Create session const session = await Session.createSession({ userId: user.id, userAgent: options.userAgent || '', ipAddress: options.ipAddress || '', }); // Generate tokens using the existing AuthService approach const accessToken = await this.generateAccessToken(user, session.id); const refreshToken = await this.generateRefreshToken(user, session.id); // Update user last login user.lastLoginAt = new Date(); await user.save(); // Audit log await AuditService.withContext({ actorId: user.id, actorType: 'user', actorIp: options.ipAddress, actorUserAgent: options.userAgent, }).log('USER_LOGIN', 'user', { resourceId: user.id, success: true, metadata: { providerId: provider.id, providerName: provider.name, isNewUser: isNew, authMethod: 'oauth', }, }); return { success: true, user, accessToken, refreshToken, sessionId: session.id, }; } /** * Authenticate with LDAP credentials */ public async authenticateLdap( providerId: string, username: string, password: string, options: { ipAddress?: string; userAgent?: string } = {} ): Promise { const provider = await AuthProvider.findById(providerId); if (!provider || provider.status !== 'active' || provider.type !== 'ldap') { return { success: false, errorCode: 'INVALID_PROVIDER', errorMessage: 'Invalid LDAP provider', }; } const strategy = this.strategyFactory.create(provider); if (!strategy.authenticateCredentials) { return { success: false, errorCode: 'INVALID_PROVIDER', errorMessage: 'Provider does not support credential authentication', }; } let externalUser: IExternalUserInfo; try { externalUser = await strategy.authenticateCredentials(username, password); } catch (error) { await this.auditService.log('USER_LOGIN', 'user', { success: false, metadata: { providerId: provider.id, providerName: provider.name, username, error: error instanceof Error ? error.message : String(error), }, }); return { success: false, errorCode: 'AUTH_FAILED', errorMessage: 'Invalid credentials', }; } // Find or create user const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options); // Create session const session = await Session.createSession({ userId: user.id, userAgent: options.userAgent || '', ipAddress: options.ipAddress || '', }); // Generate tokens const accessToken = await this.generateAccessToken(user, session.id); const refreshToken = await this.generateRefreshToken(user, session.id); // Update user last login user.lastLoginAt = new Date(); await user.save(); // Audit log await AuditService.withContext({ actorId: user.id, actorType: 'user', actorIp: options.ipAddress, actorUserAgent: options.userAgent, }).log('USER_LOGIN', 'user', { resourceId: user.id, success: true, metadata: { providerId: provider.id, providerName: provider.name, isNewUser: isNew, authMethod: 'ldap', }, }); return { success: true, user, accessToken, refreshToken, sessionId: session.id, }; } /** * Link an external provider to an existing user */ public async linkProvider( userId: string, providerId: string, externalUser: IExternalUserInfo ): Promise { // Check if this external ID is already linked to another user const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId); if (existing) { if (existing.userId === userId) { // Already linked to this user, just update await existing.updateAttributes({ externalEmail: externalUser.email, externalUsername: externalUser.username, rawAttributes: externalUser.rawAttributes, }); return existing; } throw new Error('This external account is already linked to another user'); } // Create new identity link const identity = await ExternalIdentity.createIdentity({ userId, providerId, externalId: externalUser.externalId, externalEmail: externalUser.email, externalUsername: externalUser.username, rawAttributes: externalUser.rawAttributes, }); // Update user's external identity IDs const user = await User.findById(userId); if (user) { user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id]; await user.save(); } // Audit log await this.auditService.log('USER_UPDATED', 'user', { resourceId: userId, success: true, metadata: { action: 'link_provider', providerId, externalId: externalUser.externalId, }, }); return identity; } /** * Unlink an external provider from a user */ public async unlinkProvider(userId: string, providerId: string): Promise { const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId); if (!identity) { return false; } // Ensure user still has another auth method const user = await User.findById(userId); if (!user) return false; const otherIdentities = await ExternalIdentity.findByUserId(userId); const hasLocalAuth = user.canUseLocalAuth && user.passwordHash; if (otherIdentities.length <= 1 && !hasLocalAuth) { throw new Error('Cannot unlink last authentication method'); } // Remove identity await identity.delete(); // Update user's external identity IDs user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id); await user.save(); // Audit log await this.auditService.log('USER_UPDATED', 'user', { resourceId: userId, success: true, metadata: { action: 'unlink_provider', providerId, }, }); return true; } /** * Test provider connection */ public async testConnection(providerId: string): Promise { const provider = await AuthProvider.findById(providerId); if (!provider) { return { success: false, latencyMs: 0, error: 'Provider not found', }; } const strategy = this.strategyFactory.create(provider); const result = await strategy.testConnection(); // Update provider test status await provider.updateTestResult(result.success, result.error); return result; } /** * Find or create user from external authentication */ private async findOrCreateUser( provider: AuthProvider, externalUser: IExternalUserInfo, options: { ipAddress?: string } = {} ): Promise<{ user: User; isNew: boolean }> { // 1. Check if external identity already exists const existingIdentity = await ExternalIdentity.findByExternalId( provider.id, externalUser.externalId ); if (existingIdentity) { const user = await User.findById(existingIdentity.userId); if (user) { // Update identity with latest info await existingIdentity.updateAttributes({ externalEmail: externalUser.email, externalUsername: externalUser.username, rawAttributes: externalUser.rawAttributes, }); return { user, isNew: false }; } } // 2. Try to link by email if enabled if (provider.provisioning.autoLinkByEmail && externalUser.email) { const existingUser = await User.findByEmail(externalUser.email); if (existingUser) { await this.linkProvider(existingUser.id, provider.id, externalUser); return { user: existingUser, isNew: false }; } } // 3. Create new user if JIT is enabled if (!provider.provisioning.jitEnabled) { throw new Error('User not found and JIT provisioning is disabled'); } // Check domain restrictions if (provider.provisioning.allowedEmailDomains?.length) { const domain = externalUser.email.split('@')[1]; if (!provider.provisioning.allowedEmailDomains.includes(domain)) { throw new Error(`Email domain ${domain} is not allowed`); } } // Generate unique username let username = externalUser.username || externalUser.email.split('@')[0]; username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-'); // Ensure username is unique let counter = 0; let finalUsername = username; while (await User.findByUsername(finalUsername)) { counter++; finalUsername = `${username}${counter}`; } // Create user const user = new User(); user.id = await User.getNewId(); user.email = externalUser.email.toLowerCase(); user.username = finalUsername; user.displayName = externalUser.displayName || finalUsername; user.avatarUrl = externalUser.avatarUrl; user.status = 'active'; user.emailVerified = true; // Trust the provider user.canUseLocalAuth = false; // No password set user.provisionedByProviderId = provider.id; user.passwordHash = ''; // No local password user.createdAt = new Date(); user.updatedAt = new Date(); await user.save(); // Link external identity await this.linkProvider(user.id, provider.id, externalUser); return { user, isNew: true }; } /** * Generate OAuth state token */ private async generateState(providerId: string, returnUrl?: string): Promise { const stateData: IOAuthState = { providerId, returnUrl, nonce: crypto.randomUUID(), exp: Date.now() + 10 * 60 * 1000, // 10 minutes }; // Encode as base64 return btoa(JSON.stringify(stateData)); } /** * Validate OAuth state token */ private async validateState(state: string): Promise { try { const stateData: IOAuthState = JSON.parse(atob(state)); // Check expiration if (stateData.exp < Date.now()) { return null; } return stateData; } catch { return null; } } /** * Generate access token (mirrors AuthService logic) */ private async generateAccessToken(user: User, sessionId: string): Promise { const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production'; const now = Math.floor(Date.now() / 1000); const expiresIn = 15 * 60; // 15 minutes const payload = { sub: user.id, email: user.email, sessionId, type: 'access', iat: now, exp: now + expiresIn, }; return await this.signJwt(payload, jwtSecret); } /** * Generate refresh token (mirrors AuthService logic) */ private async generateRefreshToken(user: User, sessionId: string): Promise { const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production'; const now = Math.floor(Date.now() / 1000); const expiresIn = 7 * 24 * 60 * 60; // 7 days const payload = { sub: user.id, email: user.email, sessionId, type: 'refresh', iat: now, exp: now + expiresIn, }; return await this.signJwt(payload, jwtSecret); } /** * Sign JWT token */ private async signJwt(payload: Record, secret: string): Promise { const header = { alg: 'HS256', typ: 'JWT' }; const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); const data = `${encodedHeader}.${encodedPayload}`; const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const encodedSignature = this.base64UrlEncode( String.fromCharCode(...new Uint8Array(signature)) ); return `${data}.${encodedSignature}`; } /** * Base64 URL encode */ private base64UrlEncode(str: string): string { const base64 = btoa(str); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } } // Singleton instance export const externalAuthService = new ExternalAuthService();