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,
- },
]
},
}