import { Request, Response } from 'express'; import { cryptoJs } from 'crypto-js'; import { DyFM_Error, DyFM_Log } from '@futdevpro/fsm-dynamo'; import { DyNTS_SingletonService } from '../../../_services/base/singleton.service'; import { DyNTS_global_settings } from '../../../_collections/global-settings.const'; import { DyNTS_OAuth2_AuthService } from './oauth2.auth-service'; /** * OAuth2 Control Service implementation * * This service handles OAuth2 specific business logic and token management * * @example * const oauth2Service = DyNTS_OAuth2_ControlService.getInstance(); * await oauth2Service.handleAuthorizationRequest(req, res); */ export class DyNTS_OAuth2_ControlService extends DyNTS_SingletonService { static getInstance(): DyNTS_OAuth2_ControlService { return DyNTS_OAuth2_ControlService.getSingletonInstance(); } readonly serviceName: string = 'OAuth2ControlService'; private readonly authService: DyNTS_OAuth2_AuthService = DyNTS_OAuth2_AuthService.getInstance(); private readonly authorizationCodes: Map = new Map(); private readonly accessTokens: Map = new Map(); private readonly refreshTokens: Map = new Map(); private readonly clients: Map = new Map(); private readonly users: Map = new Map(); /** * Handles the OAuth2 authorization request * @param req Express Request object * @param res Express Response object */ async handleAuthorizationRequest(req: Request, res: Response): Promise { try { const { response_type, client_id, redirect_uri, scope, state } = req.query; // Validate required parameters if (!response_type || !client_id || !redirect_uri) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HA0`, addECToUserMsg: true, message: 'Missing required OAuth2 parameters', userMessage: 'Invalid authorization request', issuerService: this.serviceName, }); } // Validate client_id against registered clients if (!this.isValidClient(client_id as string)) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HA2`, addECToUserMsg: true, message: 'Invalid client_id', userMessage: 'Invalid authorization request', issuerService: this.serviceName, }); } // Validate redirect_uri against registered redirect URIs if (!this.isValidRedirectUri(client_id as string, redirect_uri as string)) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HA3`, addECToUserMsg: true, message: 'Invalid redirect_uri', userMessage: 'Invalid authorization request', issuerService: this.serviceName, }); } // Validate scope against allowed scopes if (!this.isValidScope(client_id as string, scope as string)) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HA4`, addECToUserMsg: true, message: 'Invalid scope', userMessage: 'Invalid authorization request', issuerService: this.serviceName, }); } // For authorization code flow if (response_type === 'code') { const authorizationCode = await this.generateAuthorizationCode(client_id as string, scope as string); // Redirect with authorization code const redirectUrl = new URL(redirect_uri as string); redirectUrl.searchParams.append('code', authorizationCode); if (state) redirectUrl.searchParams.append('state', state as string); res.redirect(redirectUrl.toString()); return; } // For implicit flow if (response_type === 'token') { const accessToken = await this.generateAccessToken(client_id as string, scope as string); // Redirect with access token const redirectUrl = new URL(redirect_uri as string); redirectUrl.hash = `access_token=${accessToken}`; if (state) redirectUrl.hash += `&state=${state}`; res.redirect(redirectUrl.toString()); return; } throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HA1`, addECToUserMsg: true, message: 'Unsupported response_type', userMessage: 'Invalid authorization request', issuerService: this.serviceName, }); } catch (error) { DyFM_Log.error('Authorization request failed', error); throw error; } } /** * Validates if the client is registered and active * @param clientId The client ID to validate * @returns true if the client is valid */ private isValidClient(clientId: string): boolean { const client = this.clients.get(clientId); return client?.isActive ?? false; } /** * Validates if the redirect URI is registered for the client * @param clientId The client ID * @param redirectUri The redirect URI to validate * @returns true if the redirect URI is valid */ private isValidRedirectUri(clientId: string, redirectUri: string): boolean { const client = this.clients.get(clientId); if (!client) return false; // Check if the redirect URI matches any of the registered URIs return client.redirectUris.some(uri => { // Simple exact match for now // TODO: Implement more sophisticated URI matching (e.g., wildcards, regex) return uri === redirectUri; }); } /** * Validates if the scope is allowed for the client * @param clientId The client ID * @param scope The scope to validate * @returns true if the scope is valid */ private isValidScope(clientId: string, scope: string): boolean { const client = this.clients.get(clientId); if (!client) return false; // If no scope is requested, it's valid if (!scope) return true; // Split scope string into individual scopes const requestedScopes = scope.split(' '); // Check if all requested scopes are allowed return requestedScopes.every(s => client.allowedScopes.includes(s)); } /** * Handles the OAuth2 token request * @param req Express Request object * @param res Express Response object */ async handleTokenRequest(req: Request, res: Response): Promise { try { const { grant_type, code, refresh_token, client_id, client_secret, username, password } = req.body; // Validate required parameters if (!grant_type || !client_id || !client_secret) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT0`, addECToUserMsg: true, message: 'Missing required OAuth2 parameters', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Validate client credentials if (!this.validateClientCredentials(client_id, client_secret)) { throw new DyFM_Error({ status: 401, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT4`, addECToUserMsg: true, message: 'Invalid client credentials', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } switch (grant_type) { case 'authorization_code': if (!code) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT1`, addECToUserMsg: true, message: 'Missing authorization code', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Validate authorization code const authCodeData = this.authorizationCodes.get(code); if (!authCodeData || authCodeData.expiresAt < Date.now()) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT5`, addECToUserMsg: true, message: 'Invalid or expired authorization code', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Remove used authorization code this.authorizationCodes.delete(code); const accessToken = await this.generateAccessToken(client_id, authCodeData.scope); const refreshToken = await this.generateRefreshToken(client_id); // Store refresh token with access token reference this.refreshTokens.set(refreshToken, { clientId: client_id, scope: authCodeData.scope, accessToken }); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: 3600, refresh_token: refreshToken, scope: authCodeData.scope }); break; case 'refresh_token': if (!refresh_token) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT2`, addECToUserMsg: true, message: 'Missing refresh token', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Validate refresh token const refreshTokenData = this.refreshTokens.get(refresh_token); if (!refreshTokenData) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT6`, addECToUserMsg: true, message: 'Invalid refresh token', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Revoke old access token this.accessTokens.delete(refreshTokenData.accessToken); // Generate new access token const newAccessToken = await this.generateAccessToken(client_id, refreshTokenData.scope); const newRefreshToken = await this.generateRefreshToken(client_id); // Store new refresh token this.refreshTokens.set(newRefreshToken, { clientId: client_id, scope: refreshTokenData.scope, accessToken: newAccessToken }); res.json({ access_token: newAccessToken, token_type: 'Bearer', expires_in: 3600, refresh_token: newRefreshToken, scope: refreshTokenData.scope }); break; case 'client_credentials': const clientAccessToken = await this.generateAccessToken(client_id, ''); res.json({ access_token: clientAccessToken, token_type: 'Bearer', expires_in: 3600 }); break; case 'password': if (!username || !password) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT7`, addECToUserMsg: true, message: 'Missing username or password', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Authenticate user const userScopes = this.authenticateUser(username, password); if (!userScopes) { throw new DyFM_Error({ status: 401, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT8`, addECToUserMsg: true, message: 'Invalid username or password', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } // Generate access token const userAccessToken = await this.generateAccessToken(client_id, userScopes.join(' ')); const userRefreshToken = await this.generateRefreshToken(client_id); // Store refresh token with access token reference this.refreshTokens.set(userRefreshToken, { clientId: client_id, scope: userScopes.join(' '), accessToken: userAccessToken }); res.json({ access_token: userAccessToken, token_type: 'Bearer', expires_in: 3600, refresh_token: userRefreshToken, scope: userScopes.join(' ') }); break; default: throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HT3`, addECToUserMsg: true, message: 'Unsupported grant_type', userMessage: 'Invalid token request', issuerService: this.serviceName, }); } } catch (error) { DyFM_Log.error('Token request failed', error); throw error; } } /** * Validates client credentials * @param clientId The client ID * @param clientSecret The client secret * @returns true if the credentials are valid */ private validateClientCredentials(clientId: string, clientSecret: string): boolean { const client = this.clients.get(clientId); return (client?.clientSecret === clientSecret) && (client?.isActive ?? false); } /** * Handles the OAuth2 userinfo request * @param req Express Request object * @param res Express Response object */ async handleUserInfoRequest(req: Request, res: Response): Promise { try { const token = this.authService.getTokenFromRequest(req); // Validate token const tokenData = this.accessTokens.get(token); if (!tokenData || tokenData.expiresAt < Date.now()) { throw new DyFM_Error({ status: 401, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HU0`, addECToUserMsg: true, message: 'Invalid or expired access token', userMessage: 'Invalid token', issuerService: this.serviceName, }); } // Extract user information based on token scope const userInfo = await this.getUserInfoFromToken(token); res.json(userInfo); } catch (error) { DyFM_Log.error('Userinfo request failed', error); throw error; } } /** * Gets user information from the token * @param token The access token * @returns The user information object */ private async getUserInfoFromToken(token: string): Promise { const tokenData = this.accessTokens.get(token); if (!tokenData) { throw new DyFM_Error({ status: 401, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HU1`, addECToUserMsg: true, message: 'Invalid access token', userMessage: 'Invalid token', issuerService: this.serviceName, }); } // TODO: Implement user information retrieval from database/storage // For now, return mock user information return { sub: 'user123', name: 'John Doe', email: 'john.doe@example.com', // Add other user information based on scope ...(tokenData.scope.includes('profile') && { given_name: 'John', family_name: 'Doe', picture: 'https://example.com/john.jpg' }), ...(tokenData.scope.includes('email') && { email_verified: true }) }; } /** * Handles the OAuth2 token revocation request * @param req Express Request object * @param res Express Response object */ async handleTokenRevocation(req: Request, res: Response): Promise { try { const { token, token_type_hint } = req.body; if (!token) { throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HR0`, addECToUserMsg: true, message: 'Missing token', userMessage: 'Invalid revocation request', issuerService: this.serviceName, }); } // Try to revoke the token based on token_type_hint let revoked = false; if (!token_type_hint || token_type_hint === 'access_token') { // Try to revoke as access token if (this.accessTokens.delete(token)) { revoked = true; } } if (!revoked && (!token_type_hint || token_type_hint === 'refresh_token')) { // Try to revoke as refresh token const refreshTokenData = this.refreshTokens.get(token); if (refreshTokenData) { // Also revoke the associated access token this.accessTokens.delete(refreshTokenData.accessToken); this.refreshTokens.delete(token); revoked = true; } } if (!revoked) { // Token not found or already revoked throw new DyFM_Error({ status: 400, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-OA2-HR1`, addECToUserMsg: true, message: 'Token not found or already revoked', userMessage: 'Invalid revocation request', issuerService: this.serviceName, }); } res.status(200).send(); } catch (error) { DyFM_Log.error('Token revocation failed', error); throw error; } } /** * Generates an authorization code * @param clientId The client ID * @param scope The requested scope * @returns The generated authorization code */ private async generateAuthorizationCode(clientId: string, scope: string): Promise { //const code = randomBytes(32).toString('hex'); const code = cryptoJs.lib.WordArray.random(32).toString(); const expiresAt = Date.now() + 600000; // 10 minutes expiration this.authorizationCodes.set(code, { clientId, scope, expiresAt }); return code; } /** * Generates an access token * @param clientId The client ID * @param scope The requested scope * @returns The generated access token */ private async generateAccessToken(clientId: string, scope: string): Promise { //const token = randomBytes(32).toString('hex'); const token = cryptoJs.lib.WordArray.random(32).toString(); const expiresAt = Date.now() + 3600000; // 1 hour expiration this.accessTokens.set(token, { clientId, scope, expiresAt }); return token; } /** * Generates a refresh token * @param clientId The client ID * @returns The generated refresh token */ private async generateRefreshToken(clientId: string): Promise { //const token = randomBytes(32).toString('hex'); const token = cryptoJs.lib.WordArray.random(32).toString(); this.refreshTokens.set(token, { clientId, scope: '', accessToken: '' }); return token; } /** * Gets the access token data * @param token The access token * @returns The access token data or undefined if not found */ getAccessTokenData(token: string): { clientId: string; scope: string; expiresAt: number } | undefined { return this.accessTokens.get(token); } /** * Registers a new OAuth2 client * @param clientId The client ID * @param clientSecret The client secret * @param redirectUris The allowed redirect URIs * @param allowedScopes The allowed scopes * @returns true if the client was registered successfully */ registerClient( clientId: string, clientSecret: string, redirectUris: string[], allowedScopes: string[] ): boolean { if (this.clients.has(clientId)) { return false; } this.clients.set(clientId, { clientId, clientSecret, redirectUris, allowedScopes, isActive: true }); return true; } /** * Authenticates a user with username and password * @param username The username * @param password The password * @returns The user's scopes if authentication is successful, undefined otherwise */ private authenticateUser(username: string, password: string): string[] | undefined { const user = this.users.get(username); if (!user || user.password !== password) { // In a real implementation, compare hashed passwords return undefined; } return user.scopes; } /** * Registers a new user * @param username The username * @param password The password * @param scopes The user's scopes * @returns true if the user was registered successfully */ registerUser(username: string, password: string, scopes: string[]): boolean { if (this.users.has(username)) { return false; } this.users.set(username, { username, password, // In a real implementation, hash the password scopes }); return true; } }