diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index c09f8e2..bad824b 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -42,8 +42,8 @@ function AuthCallbackContent() { processedRef.current = true - const storedState = localStorage.getItem('oauth_state') - const codeVerifier = localStorage.getItem('oauth_code_verifier') + const storedState = sessionStorage.getItem('oauth_state') + const codeVerifier = sessionStorage.getItem('oauth_code_verifier') if (state !== storedState) { console.error('State mismatch', { received: state, stored: storedState }) @@ -53,7 +53,7 @@ function AuthCallbackContent() { const exchangeCode = async () => { try { - const authApiUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || 'http://localhost:8081' + const authApiUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' const res = await fetch(`${authApiUrl}/oauth/token`, { method: 'POST', headers: { @@ -83,8 +83,8 @@ function AuthCallbackContent() { totp_enabled: payload.totp_enabled || false }) - localStorage.removeItem('oauth_state') - localStorage.removeItem('oauth_code_verifier') + sessionStorage.removeItem('oauth_state') + sessionStorage.removeItem('oauth_code_verifier') router.push('/') } catch (err: any) { diff --git a/app/page.tsx b/app/page.tsx index cb5e874..e34e846 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,29 +1,47 @@ 'use client' -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' import { useAuth } from '@/lib/auth/context' -import { listSites } from '@/lib/api/sites' -import type { Site } from '@/lib/api/sites' +import { initiateOAuthFlow } from '@/lib/api/oauth' import LoadingOverlay from '@/components/LoadingOverlay' import SiteList from '@/components/sites/SiteList' export default function HomePage() { const { user, loading } = useAuth() - const router = useRouter() - - useEffect(() => { - if (!loading && !user) { - router.push('/login') - } - }, [user, loading, router]) if (loading) { return } if (!user) { - return null + return ( +
+
+

+ Welcome to Ciphera Analytics +

+

+ Privacy-first web analytics. No cookies, no tracking. GDPR compliant. +

+
+ + +
+
+
+ ) } return ( diff --git a/lib/api/oauth.ts b/lib/api/oauth.ts new file mode 100644 index 0000000..3b9d1ed --- /dev/null +++ b/lib/api/oauth.ts @@ -0,0 +1,65 @@ +/** + * OAuth 2.0 PKCE utilities + */ + +function generateRandomString(length: number): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + const values = new Uint8Array(length) + crypto.getRandomValues(values) + return Array.from(values, (x) => charset[x % charset.length]).join('') +} + +async function generateCodeChallenge(codeVerifier: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + const digest = await crypto.subtle.digest('SHA-256', data) + + // Convert ArrayBuffer to Base64URL string + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +export interface OAuthParams { + state: string + codeVerifier: string + codeChallenge: string +} + +export async function generateOAuthParams(): Promise { + const state = generateRandomString(32) + const codeVerifier = generateRandomString(64) + const codeChallenge = await generateCodeChallenge(codeVerifier) + + return { + state, + codeVerifier, + codeChallenge + } +} + +export async function initiateOAuthFlow(redirectPath = '/auth/callback') { + if (typeof window === 'undefined') return + + const params = await generateOAuthParams() + const redirectUri = `${window.location.origin}${redirectPath}` + + // Store PKCE params in sessionStorage for later use + sessionStorage.setItem('oauth_state', params.state) + sessionStorage.setItem('oauth_code_verifier', params.codeVerifier) + + const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || 'https://auth.ciphera.net' + const authApiUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' + + // Redirect to OAuth authorize endpoint + const authorizeUrl = new URL(`${authApiUrl}/oauth/authorize`) + authorizeUrl.searchParams.set('client_id', 'analytics-app') + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('state', params.state) + authorizeUrl.searchParams.set('code_challenge', params.codeChallenge) + authorizeUrl.searchParams.set('code_challenge_method', 'S256') + + window.location.href = authorizeUrl.toString() +} diff --git a/next.config.ts b/next.config.ts index 48c76ec..51d79fe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,23 +7,12 @@ const nextConfig: NextConfig = { // * Privacy-first: Disable analytics and telemetry productionBrowserSourceMaps: false, async redirects() { - const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || 'https://auth.ciphera.net' return [ { source: '/dashboard', destination: '/', permanent: false, }, - { - source: '/login', - destination: `${authUrl}/login?client_id=analytics-app&redirect_uri=${encodeURIComponent((process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003') + '/auth/callback')}&response_type=code`, - permanent: false, - }, - { - source: '/signup', - destination: `${authUrl}/signup?client_id=analytics-app&redirect_uri=${encodeURIComponent((process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003') + '/auth/callback')}&response_type=code`, - permanent: false, - }, ] }, }