diff --git a/services/navigation/navigation.interface.ts b/services/navigation/navigation.interface.ts new file mode 100644 index 0000000..494b412 --- /dev/null +++ b/services/navigation/navigation.interface.ts @@ -0,0 +1,139 @@ +/** + * Navigation Service Interface + * + * Provides an abstraction layer for browser navigation operations, + * enabling better testability and dependency injection for OAuth flows. + */ + +export interface NavigationService { + /** + * Redirects the browser to the specified URL + * @param url - The URL to navigate to + */ + redirectTo(url: string): void; + + /** + * Gets the current URL search parameters + * @returns URLSearchParams object containing query parameters + */ + getSearchParams(): URLSearchParams; + + /** + * Gets the current origin (protocol + hostname + port) + * @returns The current origin string + */ + getOrigin(): string; + + /** + * Gets the current pathname + * @returns The current pathname string + */ + getPathname(): string; + + /** + * Replaces the current URL without triggering navigation + * @param url - The new URL to replace with + */ + replaceUrl(url: string): void; +} + +/** + * Browser Navigation Service Implementation + * + * Real implementation that uses the browser's window.location API + */ +export class BrowserNavigationService implements NavigationService { + redirectTo(url: string): void { + if (typeof window !== 'undefined') { + window.location.href = url; + } + } + + getSearchParams(): URLSearchParams { + if (typeof window !== 'undefined') { + return new URLSearchParams(window.location.search); + } + return new URLSearchParams(); + } + + getOrigin(): string { + if (typeof window !== 'undefined') { + return window.location.origin; + } + return 'http://localhost:3000'; + } + + getPathname(): string { + if (typeof window !== 'undefined') { + return window.location.pathname; + } + return '/'; + } + + replaceUrl(url: string): void { + if (typeof window !== 'undefined' && window.history) { + window.history.replaceState(null, '', url); + } + } +} + +/** + * Mock Navigation Service Implementation + * + * Test implementation that captures navigation calls for testing + */ +export class MockNavigationService implements NavigationService { + public redirectCalls: string[] = []; + public mockSearchParams: URLSearchParams; + public mockOrigin: string; + public mockPathname: string; + public replaceCalls: string[] = []; + + constructor( + searchParams: string = '', + origin: string = 'http://localhost:3000', + pathname: string = '/' + ) { + this.mockSearchParams = new URLSearchParams(searchParams); + this.mockOrigin = origin; + this.mockPathname = pathname; + } + + redirectTo(url: string): void { + this.redirectCalls.push(url); + } + + getSearchParams(): URLSearchParams { + return this.mockSearchParams; + } + + getOrigin(): string { + return this.mockOrigin; + } + + getPathname(): string { + return this.mockPathname; + } + + replaceUrl(url: string): void { + this.replaceCalls.push(url); + } + + // Test utilities + setSearchParams(searchParams: string): void { + this.mockSearchParams = new URLSearchParams(searchParams); + } + + getLastRedirect(): string | undefined { + return this.redirectCalls[this.redirectCalls.length - 1]; + } + + clear(): void { + this.redirectCalls = []; + this.replaceCalls = []; + this.mockSearchParams = new URLSearchParams(); + } +} + +// Default instance for production use +export const navigationService = new BrowserNavigationService(); diff --git a/services/oauth.ts b/services/oauth.ts index c2e5b6b..6912817 100644 --- a/services/oauth.ts +++ b/services/oauth.ts @@ -1,4 +1,8 @@ import { authService } from './auth/auth.service'; +import { + NavigationService, + navigationService, +} from './navigation/navigation.interface'; // Mock OAuth configuration const GOOGLE_CLIENT_ID = 'mock_google_client_id'; @@ -8,8 +12,6 @@ const GITHUB_CLIENT_ID = 'mock_github_client_id'; const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; -// Mock token exchange endpoints - // Mock OAuth scopes const GOOGLE_SCOPES = 'openid email profile'; const GITHUB_SCOPES = 'user:email'; @@ -20,115 +22,150 @@ const REDIRECT_URI = 'http://localhost:3000/auth/callback'; // Mock OAuth state generation const generateState = () => crypto.randomUUID(); -export const googleAuth = () => { - const state = generateState(); - const url = new URL(GOOGLE_AUTH_URL); - url.searchParams.append('client_id', GOOGLE_CLIENT_ID); - url.searchParams.append('response_type', 'code'); - url.searchParams.append('scope', GOOGLE_SCOPES); - url.searchParams.append('redirect_uri', REDIRECT_URI); - url.searchParams.append('state', state); +/** + * OAuth Service Class + * + * Handles OAuth authentication flows with dependency injection for navigation, + * making it more testable and maintainable. + */ +export class OAuthService { + constructor(private navigation: NavigationService = navigationService) {} - // In a real implementation, we would store the state in the session or localStorage - localStorage.setItem('oauth_state', state); + /** + * Initiates Google OAuth authentication flow + */ + googleAuth(): void { + const state = generateState(); + const url = new URL(GOOGLE_AUTH_URL); + url.searchParams.append('client_id', GOOGLE_CLIENT_ID); + url.searchParams.append('response_type', 'code'); + url.searchParams.append('scope', GOOGLE_SCOPES); + url.searchParams.append('redirect_uri', REDIRECT_URI); + url.searchParams.append('state', state); - // Redirect to Google's auth endpoint - window.location.href = url.toString(); -}; + // Store state for CSRF protection + localStorage.setItem('oauth_state', state); -export const githubAuth = () => { - const state = generateState(); - const url = new URL(GITHUB_AUTH_URL); - url.searchParams.append('client_id', GITHUB_CLIENT_ID); - url.searchParams.append('response_type', 'code'); - url.searchParams.append('scope', GITHUB_SCOPES); - url.searchParams.append('redirect_uri', REDIRECT_URI); - url.searchParams.append('state', state); - - // In a real implementation, we would store the state in the session or localStorage - localStorage.setItem('oauth_state', state); - - // Redirect to GitHub's auth endpoint - window.location.href = url.toString(); -}; - -// Mock token exchange -const mockExchangeCodeForToken = async ( - provider: 'google' | 'github', - _code: string -): Promise => { - // In a real implementation, we would make a POST request to the token endpoint - // with the code, client_id, client_secret, and redirect_uri - - // For this mock, we'll just return a mock access token - return `mock_${provider}_access_token_${crypto.randomUUID()}`; -}; - -// Mock user info retrieval -const mockGetUserInfo = async ( - provider: 'google' | 'github', - _accessToken: string -): Promise<{ email: string; name: string }> => { - // In a real implementation, we would make a GET request to the user info endpoint - // with the access token - - // For this mock, we'll return mock user info - return { - email: `mock_${provider}_user_${crypto.randomUUID()}@example.com`, - name: `Mock ${provider.charAt(0).toUpperCase() + provider.slice(1)} User`, - }; -}; - -export const handleGoogleCallback = async () => { - const params = new URLSearchParams(window.location.search); - const code = params.get('code'); - const state = params.get('state'); - const storedState = localStorage.getItem('oauth_state'); - - // Verify state to prevent CSRF attacks - if (state !== storedState) { - throw new Error('Invalid OAuth state'); + // Redirect to Google's auth endpoint + this.navigation.redirectTo(url.toString()); } - // Clear stored state - localStorage.removeItem('oauth_state'); + /** + * Initiates GitHub OAuth authentication flow + */ + githubAuth(): void { + const state = generateState(); + const url = new URL(GITHUB_AUTH_URL); + url.searchParams.append('client_id', GITHUB_CLIENT_ID); + url.searchParams.append('response_type', 'code'); + url.searchParams.append('scope', GITHUB_SCOPES); + url.searchParams.append('redirect_uri', REDIRECT_URI); + url.searchParams.append('state', state); - // Exchange code for token - const accessToken = await mockExchangeCodeForToken('google', code); + // Store state for CSRF protection + localStorage.setItem('oauth_state', state); - // Get user info - const userInfo = await mockGetUserInfo('google', accessToken); - - // Register or login the user - return authService.loginWithOAuth('google', { - email: userInfo.email, - username: userInfo.name, - }); -}; - -export const handleGithubCallback = async () => { - const params = new URLSearchParams(window.location.search); - const code = params.get('code'); - const state = params.get('state'); - const storedState = localStorage.getItem('oauth_state'); - - // Verify state to prevent CSRF attacks - if (state !== storedState) { - throw new Error('Invalid OAuth state'); + // Redirect to GitHub's auth endpoint + this.navigation.redirectTo(url.toString()); } - // Clear stored state - localStorage.removeItem('oauth_state'); + /** + * Handles Google OAuth callback + */ + async handleGoogleCallback() { + const params = this.navigation.getSearchParams(); + const code = params.get('code'); + const state = params.get('state'); + const storedState = localStorage.getItem('oauth_state'); - // Exchange code for token - const accessToken = await mockExchangeCodeForToken('github', code); + // Verify state to prevent CSRF attacks + if (state !== storedState) { + throw new Error('Invalid OAuth state'); + } - // Get user info - const userInfo = await mockGetUserInfo('github', accessToken); + // Clear stored state + localStorage.removeItem('oauth_state'); - // Register or login the user - return authService.loginWithOAuth('github', { - email: userInfo.email, - username: userInfo.name, - }); -}; + // Exchange code for token + const accessToken = await this.mockExchangeCodeForToken('google', code); + + // Get user info + const userInfo = await this.mockGetUserInfo('google', accessToken); + + // Register or login the user + return authService.loginWithOAuth('google', { + email: userInfo.email, + username: userInfo.name, + }); + } + + /** + * Handles GitHub OAuth callback + */ + async handleGithubCallback() { + const params = this.navigation.getSearchParams(); + const code = params.get('code'); + const state = params.get('state'); + const storedState = localStorage.getItem('oauth_state'); + + // Verify state to prevent CSRF attacks + if (state !== storedState) { + throw new Error('Invalid OAuth state'); + } + + // Clear stored state + localStorage.removeItem('oauth_state'); + + // Exchange code for token + const accessToken = await this.mockExchangeCodeForToken('github', code); + + // Get user info + const userInfo = await this.mockGetUserInfo('github', accessToken); + + // Register or login the user + return authService.loginWithOAuth('github', { + email: userInfo.email, + username: userInfo.name, + }); + } + + /** + * Mock token exchange + * In a real implementation, this would make a POST request to the token endpoint + */ + private async mockExchangeCodeForToken( + provider: 'google' | 'github', + _code: string | null + ): Promise { + // For this mock, we'll just return a mock access token + return `mock_${provider}_access_token_${crypto.randomUUID()}`; + } + + /** + * Mock user info retrieval + * In a real implementation, this would make a GET request to the user info endpoint + */ + private async mockGetUserInfo( + provider: 'google' | 'github', + _accessToken: string + ): Promise<{ email: string; name: string }> { + // For this mock, we'll return mock user info + return { + email: `mock_${provider}_user_${crypto.randomUUID()}@example.com`, + name: `Mock ${provider.charAt(0).toUpperCase() + provider.slice(1)} User`, + }; + } +} + +// Default instance for production use +const oauthService = new OAuthService(); + +// Export both the class and convenience functions for backward compatibility +export const googleAuth = () => oauthService.googleAuth(); +export const githubAuth = () => oauthService.githubAuth(); +export const handleGoogleCallback = () => oauthService.handleGoogleCallback(); +export const handleGithubCallback = () => oauthService.handleGithubCallback(); + +// Export the service instance for direct use +export { oauthService }; +export default oauthService;