feat(auth): SmartAuth tracker-web — OAuth proxy, MFA verify, login page with Google Sign-In

- Add OAuth proxy route with productId forwarding via getRequestProductId
- Add MFA verify proxy route
- Update login page with Google Sign-In button (env-gated) and MFA challenge flow
- Fix completeAuth to avoid redundant router.push before window.location.href
- Add NEXT_PUBLIC_GOOGLE_CLIENT_ID to .env.example
- Add MessageEvent to ESLint globals for popup message handler
This commit is contained in:
saravanakumardb1 2026-03-12 11:15:44 -07:00
parent ac798a727e
commit 10494ae0e4
5 changed files with 321 additions and 1 deletions

View File

@ -14,6 +14,9 @@ PLATFORM_API_URL=http://localhost:4003
# ── Auth (JWT) ──
JWT_SECRET=
# ── SmartAuth: OAuth (optional — enables social login buttons) ──
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# ── Azure Key Vault (optional — resolves secrets at startup) ──
AZURE_KEYVAULT_URL=

View File

@ -67,6 +67,7 @@ export default [
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLButtonElement: 'readonly',
MessageEvent: 'readonly',
},
},
plugins: {

View File

@ -0,0 +1,29 @@
/**
* Proxy MFA verification to platform-service.
* POST /api/auth/mfa/verify { challengeToken, code, method }
*/
import { NextRequest, NextResponse } from 'next/server';
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const res = await fetch(`${PLATFORM_API}/api/auth/mfa/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
{ error: data.error || 'MFA verification failed' },
{ status: res.status }
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 });
}
}

View File

@ -0,0 +1,41 @@
/**
* Proxy OAuth login to platform-service.
* POST /api/auth/oauth/:provider { idToken }
*/
import { NextRequest, NextResponse } from 'next/server';
import { getRequestProductId } from '@/lib/product-config';
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
try {
const { provider } = await params;
const body = await req.json();
if (!body.idToken) {
return NextResponse.json({ error: 'idToken required' }, { status: 400 });
}
const res = await fetch(`${PLATFORM_API}/api/auth/oauth/${provider}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, productId: getRequestProductId(req) }),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
{ error: data.error || `OAuth ${provider} login failed` },
{ status: res.status }
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: 'Platform service unavailable' }, { status: 502 });
}
}

View File

@ -1,9 +1,11 @@
'use client';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@ -12,6 +14,42 @@ export default function LoginPage() {
const { login } = useAuth();
const router = useRouter();
// MFA state
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
const [mfaCode, setMfaCode] = useState('');
const [useRecovery, setUseRecovery] = useState(false);
const completeAuth = useCallback(
(data: {
accessToken: string;
user: { id: string; email: string; role: string; displayName: string };
}) => {
localStorage.setItem('tracker_token', data.accessToken);
// Force full reload so auth-context re-reads token from localStorage
window.location.href = '/dashboard';
},
[]
);
const handleLoginResponse = useCallback(
(data: Record<string, unknown>) => {
if (data.mfaRequired) {
setMfaChallenge(data.mfaChallenge as string);
setMfaMethods((data.methods as string[]) || []);
setError('');
return;
}
completeAuth(
data as {
accessToken: string;
user: { id: string; email: string; role: string; displayName: string };
}
);
},
[completeAuth]
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@ -26,6 +64,178 @@ export default function LoginPage() {
}
};
const handleGoogleSignIn = useCallback(async () => {
setError('');
setLoading(true);
try {
const google = (window as unknown as Record<string, unknown>).google as
| {
accounts: {
id: {
initialize: (config: Record<string, unknown>) => void;
prompt: () => void;
};
};
}
| undefined;
if (google) {
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: async (response: { credential: string }) => {
const res = await fetch('/api/auth/oauth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken: response.credential }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data.error || 'Google sign-in failed');
return;
}
handleLoginResponse(data);
},
});
google.accounts.id.prompt();
} else {
// Fallback: popup
const w = 500,
h = 600;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
const popup = window.open(
`https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${GOOGLE_CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(window.location.origin + '/api/auth/callback/google')}&` +
`response_type=id_token&scope=openid email profile&nonce=${crypto.randomUUID()}`,
'google-signin',
`width=${w},height=${h},left=${left},top=${top}`
);
const handler = (event: MessageEvent) => {
if (event.origin === window.location.origin && event.data?.idToken) {
window.removeEventListener('message', handler);
popup?.close();
fetch('/api/auth/oauth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken: event.data.idToken }),
})
.then(r => r.json())
.then(data => {
setLoading(false);
if (data.error) {
setError(data.error);
return;
}
handleLoginResponse(data);
});
}
};
window.addEventListener('message', handler);
}
} catch {
setError('Google sign-in failed');
setLoading(false);
}
}, [handleLoginResponse]);
const handleMfaVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/mfa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeToken: mfaChallenge,
code: mfaCode,
method: useRecovery ? 'recovery' : 'totp',
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Verification failed');
return;
}
completeAuth(data);
} catch {
setError('Service unavailable');
} finally {
setLoading(false);
}
};
// MFA challenge view
if (mfaChallenge) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 rounded-xl border border-border bg-card p-8 shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight">Two-Factor Auth</h1>
<p className="mt-1 text-sm text-muted-foreground">
{useRecovery ? 'Enter a recovery code' : 'Enter your authentication code'}
</p>
{mfaMethods.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">Methods: {mfaMethods.join(', ')}</p>
)}
</div>
<form onSubmit={handleMfaVerify} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={useRecovery ? 'Recovery code' : '000000'}
value={mfaCode}
onChange={e => setMfaCode(e.target.value)}
required
maxLength={useRecovery ? 20 : 6}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-center text-lg font-mono tracking-widest outline-none ring-ring focus:ring-2"
autoFocus
/>
<button
type="submit"
disabled={loading || mfaCode.length < 6}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Verifying...' : 'Verify'}
</button>
<div className="flex justify-between text-xs">
<button
type="button"
onClick={() => {
setMfaChallenge(null);
setMfaMethods([]);
setMfaCode('');
}}
className="text-muted-foreground underline"
>
Back to login
</button>
<button
type="button"
onClick={() => {
setUseRecovery(!useRecovery);
setMfaCode('');
setError('');
}}
className="text-primary underline"
>
{useRecovery ? 'Use authenticator' : 'Use recovery code'}
</button>
</div>
</form>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 rounded-xl border border-border bg-card p-8 shadow-lg">
@ -81,6 +291,42 @@ export default function LoginPage() {
</button>
</form>
{GOOGLE_CLIENT_ID && (
<>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<hr className="flex-1 border-border" />
or
<hr className="flex-1 border-border" />
</div>
<button
type="button"
onClick={handleGoogleSignIn}
disabled={loading}
className="w-full rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Sign in with Google
</button>
</>
)}
<p className="text-center text-xs text-muted-foreground">
Uses platform-service credentials
</p>