/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useState, useRef, useEffect } from 'react' import type { AxiosRequestConfig } from 'axios' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' import { getOAuthState } from '../api' import { buildGitHubOAuthUrl, buildDiscordOAuthUrl, buildOIDCOAuthUrl, buildLinuxDOOAuthUrl, } from '../lib/oauth' import type { SystemStatus, CustomOAuthProviderInfo } from '../types' /** * Generate a random code verifier for PKCE */ function generateCodeVerifier(): string { const array = new Uint8Array(32) crypto.getRandomValues(array) return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } /** * Generate code challenge from code verifier using SHA-256 */ async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(verifier) const digest = await crypto.subtle.digest('SHA-256', data) return btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } type LogoutRequestConfig = AxiosRequestConfig & { skipErrorHandler?: boolean } /** * Hook for managing OAuth login */ export function useOAuthLogin(status: SystemStatus | null) { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) const [githubButtonText, setGithubButtonText] = useState('') const [githubButtonDisabled, setGithubButtonDisabled] = useState(false) const githubTimeoutRef = useRef(null) const { auth } = useAuthStore() useEffect(() => { setGithubButtonText(t('Continue with GitHub')) return () => { if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } } }, [t]) const resetSession = async () => { try { auth.reset() } catch (_error) { // ignore store reset errors } try { await api.get('/api/user/logout', { skipErrorHandler: true, } as LogoutRequestConfig) } catch (_error) { // ignore logout errors } } const handleGitHubLogin = async () => { if (!status?.github_client_id) return if (githubButtonDisabled) return setIsLoading(true) setGithubButtonDisabled(true) setGithubButtonText(t('Redirecting to GitHub...')) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } githubTimeoutRef.current = setTimeout(() => { setIsLoading(false) setGithubButtonText( t('Request timed out, please refresh and restart GitHub login') ) setGithubButtonDisabled(true) }, 20000) try { await resetSession() const state = await getOAuthState() if (!state) { toast.error(t('Failed to initialize OAuth')) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) setGithubButtonText(t('Continue with GitHub')) setGithubButtonDisabled(false) return } const url = buildGitHubOAuthUrl(status.github_client_id, state) window.open(url, '_self') } catch (_error) { toast.error(t('Failed to start GitHub login')) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) setGithubButtonText(t('Continue with GitHub')) setGithubButtonDisabled(false) } } const handleDiscordLogin = async () => { if (!status?.discord_client_id) return setIsLoading(true) try { await resetSession() const state = await getOAuthState() if (!state) { toast.error(t('Failed to initialize OAuth')) return } const url = buildDiscordOAuthUrl(status.discord_client_id, state) window.open(url, '_self') } catch (_error) { toast.error(t('Failed to start Discord login')) } finally { setIsLoading(false) } } const handleOIDCLogin = async () => { if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return setIsLoading(true) try { await resetSession() const state = await getOAuthState() if (!state) { toast.error(t('Failed to initialize OAuth')) return } const url = buildOIDCOAuthUrl( status.oidc_authorization_endpoint, status.oidc_client_id, state ) window.open(url, '_self') } catch (_error) { toast.error(t('Failed to start OIDC login')) } finally { setIsLoading(false) } } const handleLinuxDOLogin = async () => { if (!status?.linuxdo_client_id) return setIsLoading(true) try { await resetSession() const state = await getOAuthState() if (!state) { toast.error(t('Failed to initialize OAuth')) return } const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state) window.open(url, '_self') } catch (_error) { toast.error(t('Failed to start LinuxDO login')) } finally { setIsLoading(false) } } const handleTelegramLogin = () => { toast.info(t('Telegram login requires widget integration; coming soon')) } const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => { if (!provider.authorization_endpoint || !provider.client_id) return setIsLoading(true) try { await resetSession() const state = await getOAuthState() if (!state) { toast.error(t('Failed to initialize OAuth')) return } const redirectUri = `${window.location.origin}/oauth/${provider.slug}` const url = new URL(provider.authorization_endpoint) url.searchParams.set('client_id', provider.client_id) url.searchParams.set('redirect_uri', redirectUri) url.searchParams.set('response_type', 'code') url.searchParams.set('state', state) if (provider.scopes) { url.searchParams.set('scope', provider.scopes) } // Add PKCE support if enabled if (provider.pkce_enabled) { const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) // Store code_verifier in sessionStorage keyed by state sessionStorage.setItem(`pkce_verifier_${state}`, codeVerifier) url.searchParams.set('code_challenge', codeChallenge) url.searchParams.set('code_challenge_method', 'S256') } window.open(url.toString(), '_self') } catch (_error) { toast.error( t('Failed to start {{provider}} login', { provider: provider.name }) ) } finally { setIsLoading(false) } } return { isLoading, githubButtonText, githubButtonDisabled, handleGitHubLogin, handleDiscordLogin, handleOIDCLogin, handleLinuxDOLogin, handleTelegramLogin, handleCustomOAuthLogin, } }