Merge pull request #1 from jamesii-b/oidc/v1.01
Feat: Handle Oauth Callback/Bridge + Microsoft Oauth
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
||||
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.env
|
||||
Generated
+17
-1
@@ -2859,6 +2859,7 @@
|
||||
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2876,6 +2877,7 @@
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2887,6 +2889,7 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -2937,6 +2940,7 @@
|
||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
@@ -3169,6 +3173,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3373,6 +3378,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -3706,6 +3712,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -3787,7 +3794,8 @@
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
@@ -3885,6 +3893,7 @@
|
||||
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5406,6 +5415,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5592,6 +5602,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5618,6 +5629,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -5631,6 +5643,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6184,6 +6197,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -6308,6 +6322,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6487,6 +6502,7 @@
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
+11
-4
@@ -132,6 +132,8 @@ export interface WebAuthnLoginCompleteResponse {
|
||||
}
|
||||
|
||||
// External Auth Types
|
||||
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
||||
|
||||
export interface ExternalProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -606,12 +608,17 @@ export const api = {
|
||||
listLinkedAccounts: () =>
|
||||
request<LinkedAccountsResponse>('/auth/external/linked-accounts'),
|
||||
|
||||
// Initiate OAuth login flow
|
||||
initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string }) =>
|
||||
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/authorize`, {
|
||||
// Initiate OAuth login flow — returns authorization_url to redirect the browser to
|
||||
initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string; flow?: string; oidc_session_id?: string }) => {
|
||||
const params = new URLSearchParams({ flow: options?.flow ?? 'login' });
|
||||
if (options?.redirect_uri) params.set('redirect_uri', options.redirect_uri);
|
||||
if (options?.organization_id) params.set('organization_id', options.organization_id);
|
||||
if (options?.oidc_session_id) params.set('oidc_session_id', options.oidc_session_id);
|
||||
return request<OAuthAuthorizeResponse>(`/auth/external/${provider}/authorize?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
}, false),
|
||||
}, false);
|
||||
},
|
||||
|
||||
// Initiate account linking flow (requires auth)
|
||||
initiateLink: (provider: string, redirect_uri?: string) =>
|
||||
|
||||
+110
-38
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,10 +23,31 @@ import {
|
||||
} from "@/lib/webauthn";
|
||||
import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
|
||||
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
|
||||
import { generateCodeVerifier, computeCodeChallenge, generateState, storeOAuthState, OAuthProvider } from "@/lib/oauth";
|
||||
import { OAuthProvider } from "@/lib/oauth";
|
||||
|
||||
type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa';
|
||||
|
||||
const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
|
||||
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
|
||||
|
||||
/**
|
||||
* Complete an OIDC authorization flow after the user has authenticated.
|
||||
* Sends the bearer token + oidc_session_id to the backend, which generates
|
||||
* the auth code and returns the redirect URL for the calling application.
|
||||
*/
|
||||
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> {
|
||||
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.success) {
|
||||
throw new Error(body.message ?? 'OIDC completion failed');
|
||||
}
|
||||
return body.data.redirect_url as string;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, verifyTotp, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -42,6 +63,28 @@ export default function LoginPage() {
|
||||
const [passkeyEmail, setPasskeyEmail] = useState("");
|
||||
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
||||
|
||||
// OIDC bridge: if oidc_session_id is in the URL, we're acting as the
|
||||
// login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse).
|
||||
// After successful login, call /oidc/complete and redirect to the client app.
|
||||
const oidcSessionId = searchParams.get('oidc_session_id');
|
||||
const oidcError = searchParams.get('error');
|
||||
|
||||
const finishOidcFlow = useCallback(async (token: string) => {
|
||||
if (!oidcSessionId) return false;
|
||||
try {
|
||||
const redirectUrl = await completeOidcFlow(oidcSessionId, token);
|
||||
window.location.href = redirectUrl;
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authorization failed",
|
||||
description: err instanceof Error ? err.message : "Could not complete OIDC authorization",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [oidcSessionId, toast]);
|
||||
|
||||
// Check for MFA step from OAuth callback
|
||||
useEffect(() => {
|
||||
if (searchParams.get('step') === 'mfa') {
|
||||
@@ -77,6 +120,10 @@ export default function LoginPage() {
|
||||
} else if (result.requiresMfaEnrollment) {
|
||||
// MFA enrollment required - will be handled by ProtectedLayout
|
||||
// Navigation happens in AuthContext
|
||||
} else if (oidcSessionId) {
|
||||
// OIDC bridge: send token back to the Gatehouse backend to complete the flow
|
||||
const token = tokenManager.getToken();
|
||||
if (token) await finishOidcFlow(token);
|
||||
}
|
||||
// If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext
|
||||
} catch (error) {
|
||||
@@ -128,12 +175,20 @@ export default function LoginPage() {
|
||||
sessionStorage.removeItem('mfa_token');
|
||||
sessionStorage.removeItem('mfa_flow');
|
||||
|
||||
// Refresh user context and navigate
|
||||
await refreshUser();
|
||||
navigate('/profile');
|
||||
// OIDC bridge: finish the flow if this is an OIDC login
|
||||
if (oidcSessionId && response.token) {
|
||||
await finishOidcFlow(response.token);
|
||||
} else {
|
||||
await refreshUser();
|
||||
navigate('/profile');
|
||||
}
|
||||
} else {
|
||||
// Fallback to regular TOTP verification
|
||||
await verifyTotp(totpCode, useBackupCode);
|
||||
if (oidcSessionId) {
|
||||
const token = tokenManager.getToken();
|
||||
if (token) await finishOidcFlow(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -173,7 +228,12 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
await verifyTotp(totpCode, useBackupCode);
|
||||
// Navigation happens in AuthContext
|
||||
// OIDC bridge: finish the flow if this is an OIDC login
|
||||
if (oidcSessionId) {
|
||||
const token = tokenManager.getToken();
|
||||
if (token) await finishOidcFlow(token);
|
||||
}
|
||||
// Otherwise navigation happens in AuthContext
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Gatehouse] TOTP verification failed:", error);
|
||||
@@ -229,7 +289,12 @@ export default function LoginPage() {
|
||||
|
||||
// Token is stored by completeLogin, refresh user and navigate
|
||||
await refreshUser();
|
||||
navigate('/profile');
|
||||
if (oidcSessionId) {
|
||||
const token = tokenManager.getToken();
|
||||
if (token) await finishOidcFlow(token);
|
||||
} else {
|
||||
navigate('/profile');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Welcome back",
|
||||
@@ -295,14 +360,18 @@ export default function LoginPage() {
|
||||
const formattedAssertion = formatLoginAssertion(assertion);
|
||||
const result = await api.webauthn.completeLogin(formattedAssertion);
|
||||
|
||||
// Token is stored by completeLogin, refresh user and navigate
|
||||
await refreshUser();
|
||||
navigate('/profile');
|
||||
|
||||
toast({
|
||||
title: "Welcome back",
|
||||
description: `Signed in as ${result.user.email}`,
|
||||
});
|
||||
// OIDC bridge or normal navigation
|
||||
if (oidcSessionId) {
|
||||
const token = tokenManager.getToken();
|
||||
if (token) await finishOidcFlow(token);
|
||||
} else {
|
||||
await refreshUser();
|
||||
navigate('/profile');
|
||||
toast({
|
||||
title: "Welcome back",
|
||||
description: `Signed in as ${result.user.email}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Gatehouse] WebAuthn verification failed:", error);
|
||||
@@ -351,35 +420,33 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate OAuth login flow for external provider
|
||||
* Initiate OAuth login flow for external provider.
|
||||
*
|
||||
* The backend /authorize endpoint builds the Google auth URL (with the
|
||||
* backend callback as redirect_uri) and returns it. We then redirect the
|
||||
* browser to Google. After the user authenticates, Google calls the backend
|
||||
* callback, the backend exchanges the code for a session token, and
|
||||
* redirects the browser to /oauth/callback?token=... on the frontend.
|
||||
*/
|
||||
const handleOAuthLogin = async (provider: OAuthProvider) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
// Store OAuth state for callback validation
|
||||
storeOAuthState({
|
||||
state,
|
||||
codeVerifier,
|
||||
try {
|
||||
// The redirect_uri Google will call is the *backend* callback.
|
||||
// The backend then redirects to the frontend /oauth/callback with the token.
|
||||
const backendCallbackUri = `${import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'}/auth/external/${provider}/callback`;
|
||||
|
||||
// Ask backend for the Google authorization URL
|
||||
// If we're in an OIDC bridge flow, pass oidc_session_id so it survives the round-trip
|
||||
const response = await api.externalAuth.initiateLogin(provider, {
|
||||
redirect_uri: backendCallbackUri,
|
||||
flow: 'login',
|
||||
provider,
|
||||
redirectUri: `${window.location.origin}/oauth/callback`,
|
||||
...(oidcSessionId ? { oidc_session_id: oidcSessionId } : {}),
|
||||
});
|
||||
|
||||
// Get authorization URL from backend
|
||||
const response = await api.externalAuth.initiateLogin(provider, state);
|
||||
// Redirect browser to provider
|
||||
window.location.href = response.authorization_url;
|
||||
|
||||
// Redirect to provider authorization page
|
||||
const authUrl = new URL(response.authorization_url);
|
||||
authUrl.searchParams.set('state', response.state || state);
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Gatehouse] OAuth login failed:", error);
|
||||
@@ -794,11 +861,16 @@ export default function LoginPage() {
|
||||
<div className="auth-card">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
Welcome back
|
||||
{oidcSessionId ? "Sign in to continue" : "Welcome back"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Sign in to your account to continue
|
||||
{oidcSessionId
|
||||
? "An application is requesting access to your account"
|
||||
: "Sign in to your account to continue"}
|
||||
</p>
|
||||
{oidcError && (
|
||||
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -4,16 +4,37 @@ import { Loader2, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api, ApiError, tokenManager, OAuthCallbackResponse } from "@/lib/api";
|
||||
import { getOAuthState, clearOAuthState } from "@/lib/oauth";
|
||||
import { tokenManager } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type CallbackState = 'loading' | 'success' | 'error';
|
||||
|
||||
const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string;
|
||||
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
|
||||
|
||||
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> {
|
||||
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.success) throw new Error(body.message ?? 'OIDC completion failed');
|
||||
return body.data.redirect_url as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth callback page that handles the redirect from external OAuth providers.
|
||||
* Extracts the authorization code and state from the URL, validates the state,
|
||||
* exchanges the code for tokens, and handles MFA requirements.
|
||||
* OAuth callback page that handles the redirect from the Gatehouse backend
|
||||
* after a successful (or failed) OAuth provider authentication.
|
||||
*
|
||||
* The backend exchanges the provider code for a session token and then
|
||||
* redirects the browser here with query params:
|
||||
*
|
||||
* Success: ?token=TOKEN&expires_in=86400&flow=login&provider=google&state=STATE
|
||||
* OIDC bridge: same as above + &oidc_session_id=ID
|
||||
* Error: ?error=MESSAGE&error_type=TYPE&state=STATE
|
||||
* Org selection: ?requires_org_selection=1&state=STATE
|
||||
* Org creation: ?requires_org_creation=1&state=STATE
|
||||
*/
|
||||
export default function OAuthCallbackPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -26,138 +47,101 @@ export default function OAuthCallbackPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
// 1. Extract query parameters from URL
|
||||
const code = searchParams.get("code");
|
||||
const callbackState = searchParams.get("state");
|
||||
const errorParam = searchParams.get("error");
|
||||
const errorDescription = searchParams.get("error_description");
|
||||
const errorType = searchParams.get("error_type");
|
||||
const token = searchParams.get("token");
|
||||
const expiresIn = searchParams.get("expires_in");
|
||||
const flowType = searchParams.get("flow") || "login";
|
||||
const provider = searchParams.get("provider") || "google";
|
||||
const requiresOrgSelection = searchParams.get("requires_org_selection");
|
||||
const requiresOrgCreation = searchParams.get("requires_org_creation");
|
||||
const oidcSessionId = searchParams.get("oidc_session_id");
|
||||
|
||||
// 2. Handle OAuth errors from provider
|
||||
// Error from provider or backend
|
||||
if (errorParam) {
|
||||
setStatus('error');
|
||||
|
||||
// User denied access
|
||||
if (errorParam === 'access_denied') {
|
||||
if (errorType === 'ACCESS_DENIED' || errorParam.toLowerCase().includes('denied')) {
|
||||
setError("You denied the authorization request. Please try again if you wish to sign in.");
|
||||
} else {
|
||||
setError(errorDescription || `Authorization failed: ${errorParam}`);
|
||||
setError(errorParam);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || !callbackState) {
|
||||
// Organisation selection required
|
||||
if (requiresOrgSelection) {
|
||||
setStatus('error');
|
||||
setError("Missing authorization code or state parameter. Please try signing in again.");
|
||||
setError("Multiple organizations found. Organization selection is not yet supported in this UI. Please contact your administrator.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Validate state parameter (CSRF protection)
|
||||
const storedState = getOAuthState(callbackState);
|
||||
if (!storedState) {
|
||||
// Organisation creation required
|
||||
if (requiresOrgCreation) {
|
||||
setStatus('error');
|
||||
setError("Invalid or expired OAuth state. Please try signing in again.");
|
||||
setError("No organization found for your account. Please ask an administrator to add you to an organization.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — token in URL
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setError("No authentication token received. Please try signing in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Exchange authorization code for tokens using the API
|
||||
const response = await api.externalAuth.handleCallback(
|
||||
storedState.provider,
|
||||
code,
|
||||
callbackState
|
||||
);
|
||||
const expiresAt = expiresIn
|
||||
? new Date(Date.now() + parseInt(expiresIn, 10) * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
// Handle error response from backend
|
||||
if (response.error) {
|
||||
setStatus('error');
|
||||
|
||||
// Map error types to user-friendly messages
|
||||
switch (response.error_type) {
|
||||
case 'ACCESS_DENIED':
|
||||
setError("You denied the authorization request. Please try again if you wish to sign in.");
|
||||
break;
|
||||
case 'INVALID_REQUEST':
|
||||
setError("Invalid request. Please try signing in again.");
|
||||
break;
|
||||
case 'SERVER_ERROR':
|
||||
setError("The authentication server encountered an error. Please try again later.");
|
||||
break;
|
||||
default:
|
||||
setError(response.error || "An error occurred during authentication.");
|
||||
}
|
||||
|
||||
clearOAuthState(callbackState);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Handle MFA requirement
|
||||
if (response.requires_mfa && response.mfa_token) {
|
||||
// Store MFA token for the MFA verification flow
|
||||
sessionStorage.setItem('mfa_token', response.mfa_token);
|
||||
sessionStorage.setItem('mfa_flow', 'external_auth');
|
||||
clearOAuthState(callbackState);
|
||||
|
||||
// Redirect to login page with MFA step
|
||||
navigate('/login?step=mfa', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Store authentication tokens
|
||||
if (response.token && response.expires_in) {
|
||||
tokenManager.setToken(response.token, new Date(Date.now() + response.expires_in * 1000).toISOString());
|
||||
}
|
||||
|
||||
// Clear OAuth state (single-use)
|
||||
clearOAuthState(callbackState);
|
||||
|
||||
// Refresh user context
|
||||
tokenManager.setToken(token, expiresAt);
|
||||
await refreshUser();
|
||||
|
||||
// ── OIDC bridge: complete the flow and redirect back to the OIDC client ──
|
||||
if (oidcSessionId) {
|
||||
try {
|
||||
const redirectUrl = await completeOidcFlow(oidcSessionId, token);
|
||||
window.location.href = redirectUrl;
|
||||
return;
|
||||
} catch (oidcErr) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Gatehouse] OIDC completion failed after OAuth:", oidcErr);
|
||||
}
|
||||
// Fall through to normal flow on failure — user is still logged in
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// Show success toast and redirect
|
||||
toast({
|
||||
title: "Sign in successful",
|
||||
description: response.user ? `Welcome, ${response.user.email}` : "You have been signed in successfully.",
|
||||
description: `Signed in with ${provider.charAt(0).toUpperCase() + provider.slice(1)}`,
|
||||
});
|
||||
|
||||
// 7. Redirect based on flow type
|
||||
setTimeout(() => {
|
||||
switch (storedState.flowType) {
|
||||
switch (flowType) {
|
||||
case 'link':
|
||||
navigate('/linked-accounts', { replace: true });
|
||||
break;
|
||||
case 'register':
|
||||
navigate('/profile', { replace: true });
|
||||
break;
|
||||
case 'login':
|
||||
default:
|
||||
navigate('/profile', { replace: true });
|
||||
}
|
||||
}, 1500);
|
||||
}, 1200);
|
||||
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
clearOAuthState(callbackState);
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
// Handle specific error types
|
||||
if (err.type === 'STATE_MISMATCH') {
|
||||
setError("CSRF protection check failed. Please try signing in again.");
|
||||
} else if (err.code === 401) {
|
||||
setError("Authentication failed. The authorization code may have expired.");
|
||||
} else {
|
||||
setError(err.message || "An unexpected error occurred during authentication.");
|
||||
}
|
||||
} else {
|
||||
setError("An unexpected error occurred. Please try signing in again.");
|
||||
setError("Failed to load your profile. Please try signing in again.");
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Gatehouse] OAuth callback refreshUser failed:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, navigate, refreshUser, toast]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -182,9 +166,7 @@ export default function OAuthCallbackPage() {
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
Authentication Failed
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{error}
|
||||
</CardDescription>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate('/login', { replace: true })} className="w-full">
|
||||
@@ -196,7 +178,6 @@ export default function OAuthCallbackPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Success state (briefly shown before redirect)
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<div className="text-center">
|
||||
@@ -209,3 +190,4 @@ export default function OAuthCallbackPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link2, Unlink, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { api, LinkedAccount, ExternalProvider, ExternalProviderId, ApiError } from "@/lib/api";
|
||||
import { storeOAuthState, generateState, generateCodeVerifier } from "@/lib/oauth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function LinkedAccountsPage() {
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [linkedAccounts, setLinkedAccounts] = useState<LinkedAccount[]>([]);
|
||||
const [providers, setProviders] = useState<ExternalProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLinking, setIsLinking] = useState<ExternalProviderId | null>(null);
|
||||
const [isUnlinking, setIsUnlinking] = useState<ExternalProviderId | null>(null);
|
||||
|
||||
// Show success toast when arriving back from OAuth link callback
|
||||
useEffect(() => {
|
||||
const linked = searchParams.get("linked");
|
||||
const provider = searchParams.get("provider");
|
||||
if (linked === "1") {
|
||||
toast({
|
||||
title: "Account linked",
|
||||
description: provider
|
||||
? `Your ${provider.charAt(0).toUpperCase() + provider.slice(1)} account has been linked.`
|
||||
: "Your account has been linked successfully.",
|
||||
});
|
||||
// Clean the query params so the toast doesn't re-fire on refresh
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
@@ -68,25 +86,17 @@ export default function LinkedAccountsPage() {
|
||||
|
||||
const handleConnect = async (provider: ExternalProviderId) => {
|
||||
setIsLinking(provider);
|
||||
|
||||
|
||||
try {
|
||||
const state = generateState();
|
||||
const codeVerifier = await generateCodeVerifier();
|
||||
|
||||
const response = await api.externalAuth.initiateLink(provider, state);
|
||||
|
||||
// Store OAuth state for callback
|
||||
storeOAuthState({
|
||||
state,
|
||||
codeVerifier,
|
||||
flow: 'link',
|
||||
provider,
|
||||
redirectUri: `${window.location.origin}/oauth/callback`,
|
||||
});
|
||||
// The backend link flow also redirects to the backend callback, which
|
||||
// then redirects to the frontend /oauth/callback with flow=link.
|
||||
const backendCallbackUri = `${import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'}/auth/external/${provider}/callback`;
|
||||
|
||||
const response = await api.externalAuth.initiateLink(provider, backendCallbackUri);
|
||||
|
||||
// Redirect to authorization
|
||||
window.location.href = response.authorization_url;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[LinkedAccounts] Connect failed:", error);
|
||||
@@ -209,7 +219,7 @@ export default function LinkedAccountsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => handleDisconnect(provider.id)}
|
||||
onClick={() => handleDisconnect(provider.id as ExternalProviderId)}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@@ -223,7 +233,7 @@ export default function LinkedAccountsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!provider.is_active || isConnecting}
|
||||
onClick={() => handleConnect(provider.id)}
|
||||
onClick={() => handleConnect(provider.id as ExternalProviderId)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
||||
Reference in New Issue
Block a user