/** * OAuth/OIDC Authentication Strategy * Handles OAuth 2.0 and OpenID Connect flows */ import type { AuthProvider } from '../../../models/auth.provider.ts'; import type { CryptoService } from '../../crypto.service.ts'; import type { IExternalUserInfo, IConnectionTestResult, } from '../../../interfaces/auth.interfaces.ts'; import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts'; interface ITokenResponse { access_token: string; token_type: string; expires_in?: number; refresh_token?: string; id_token?: string; scope?: string; } interface IOIDCDiscovery { issuer: string; authorization_endpoint: string; token_endpoint: string; userinfo_endpoint?: string; jwks_uri?: string; scopes_supported?: string[]; } export class OAuthStrategy implements IAuthStrategy { private discoveryCache: IOIDCDiscovery | null = null; constructor( private provider: AuthProvider, private cryptoService: CryptoService ) {} /** * Get the authorization URL for initiating OAuth flow */ public async getAuthorizationUrl(state: string, nonce?: string): Promise { const config = this.provider.oauthConfig; if (!config) { throw new Error('OAuth config not found'); } // Get authorization URL from config or discovery let authorizationUrl = config.authorizationUrl; if (!authorizationUrl) { const discovery = await this.getDiscovery(); authorizationUrl = discovery.authorization_endpoint; } const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.callbackUrl, response_type: 'code', scope: config.scopes.join(' '), state, }); // Add nonce for OIDC if (nonce) { params.set('nonce', nonce); } return `${authorizationUrl}?${params.toString()}`; } /** * Handle OAuth callback - exchange code for tokens and get user info */ public async handleCallback(data: IOAuthCallbackData): Promise { if (data.error) { throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`); } const config = this.provider.oauthConfig; if (!config) { throw new Error('OAuth config not found'); } // Exchange code for tokens const tokens = await this.exchangeCodeForTokens(data.code); // Get user info const userInfo = await this.fetchUserInfo(tokens.access_token); // Map attributes according to provider config return this.mapAttributes(userInfo); } /** * Test connection by fetching OIDC discovery document */ public async testConnection(): Promise { const start = Date.now(); const config = this.provider.oauthConfig; if (!config) { return { success: false, latencyMs: Date.now() - start, error: 'OAuth config not found', }; } try { const discovery = await this.getDiscovery(); return { success: true, latencyMs: Date.now() - start, serverInfo: { issuer: discovery.issuer, scopes_supported: discovery.scopes_supported, has_userinfo: !!discovery.userinfo_endpoint, }, }; } catch (error) { return { success: false, latencyMs: Date.now() - start, error: error instanceof Error ? error.message : String(error), }; } } /** * Exchange authorization code for tokens */ private async exchangeCodeForTokens(code: string): Promise { const config = this.provider.oauthConfig!; // Get token URL from config or discovery let tokenUrl = config.tokenUrl; if (!tokenUrl) { const discovery = await this.getDiscovery(); tokenUrl = discovery.token_endpoint; } // Decrypt client secret const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted); const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.callbackUrl, client_id: config.clientId, client_secret: clientSecret, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } return await response.json(); } /** * Fetch user info from the provider */ private async fetchUserInfo(accessToken: string): Promise> { const config = this.provider.oauthConfig!; // Get userinfo URL from config or discovery let userInfoUrl = config.userInfoUrl; if (!userInfoUrl) { const discovery = await this.getDiscovery(); userInfoUrl = discovery.userinfo_endpoint; } if (!userInfoUrl) { throw new Error('UserInfo endpoint not found'); } const response = await fetch(userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`); } return await response.json(); } /** * Get OIDC discovery document */ private async getDiscovery(): Promise { if (this.discoveryCache) { return this.discoveryCache; } const config = this.provider.oauthConfig!; const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`; const response = await fetch(discoveryUrl, { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error(`OIDC discovery failed: ${response.status}`); } this.discoveryCache = await response.json(); return this.discoveryCache!; } /** * Map provider attributes to standard user info */ private mapAttributes(rawInfo: Record): IExternalUserInfo { const mapping = this.provider.attributeMapping; // Get external ID (sub for OIDC, or id for OAuth2) const externalId = String(rawInfo.sub || rawInfo.id || ''); if (!externalId) { throw new Error('External ID not found in user info'); } // Get email const email = rawInfo[mapping.email]; if (!email || typeof email !== 'string') { throw new Error('Email not found in user info'); } return { externalId, email, username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined, displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined, avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl] ? String(rawInfo[mapping.avatarUrl]) : (rawInfo.picture ? String(rawInfo.picture) : undefined), groups: mapping.groups && rawInfo[mapping.groups] ? (Array.isArray(rawInfo[mapping.groups]) ? (rawInfo[mapping.groups] as string[]) : [String(rawInfo[mapping.groups])]) : undefined, rawAttributes: rawInfo, }; } }