Feat: Handle Oauth Callback/Bridge + Microsoft Oauth

This commit is contained in:
2026-02-26 23:38:45 +05:45
parent 5c2971e38d
commit b94053aebc
7 changed files with 247 additions and 157 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5000/api/v1
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.env
+17 -1
View File
@@ -2859,6 +2859,7 @@
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2876,6 +2877,7 @@
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2887,6 +2889,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -2937,6 +2940,7 @@
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.38.0",
@@ -3169,6 +3173,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3373,6 +3378,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3706,6 +3712,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -3787,7 +3794,8 @@
"version": "8.6.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.6.0", "version": "8.6.0",
@@ -3885,6 +3893,7 @@
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5406,6 +5415,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -5592,6 +5602,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -5618,6 +5629,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5631,6 +5643,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -6184,6 +6197,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -6308,6 +6322,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6487,6 +6502,7 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
+11 -4
View File
@@ -132,6 +132,8 @@ export interface WebAuthnLoginCompleteResponse {
} }
// External Auth Types // External Auth Types
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
export interface ExternalProvider { export interface ExternalProvider {
id: string; id: string;
name: string; name: string;
@@ -606,12 +608,17 @@ export const api = {
listLinkedAccounts: () => listLinkedAccounts: () =>
request<LinkedAccountsResponse>('/auth/external/linked-accounts'), request<LinkedAccountsResponse>('/auth/external/linked-accounts'),
// Initiate OAuth login flow // Initiate OAuth login flow — returns authorization_url to redirect the browser to
initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string }) => initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string; flow?: string; oidc_session_id?: string }) => {
request<OAuthAuthorizeResponse>(`/auth/external/${provider}/authorize`, { 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', method: 'GET',
credentials: 'include', credentials: 'include',
}, false), }, false);
},
// Initiate account linking flow (requires auth) // Initiate account linking flow (requires auth)
initiateLink: (provider: string, redirect_uri?: string) => initiateLink: (provider: string, redirect_uri?: string) =>
+99 -27
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react"; import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,10 +23,31 @@ import {
} from "@/lib/webauthn"; } from "@/lib/webauthn";
import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; 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'; 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() { export default function LoginPage() {
const { login, verifyTotp, refreshUser } = useAuth(); const { login, verifyTotp, refreshUser } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -42,6 +63,28 @@ export default function LoginPage() {
const [passkeyEmail, setPasskeyEmail] = useState(""); const [passkeyEmail, setPasskeyEmail] = useState("");
const [mfaToken, setMfaToken] = useState<string | null>(null); 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 // Check for MFA step from OAuth callback
useEffect(() => { useEffect(() => {
if (searchParams.get('step') === 'mfa') { if (searchParams.get('step') === 'mfa') {
@@ -77,6 +120,10 @@ export default function LoginPage() {
} else if (result.requiresMfaEnrollment) { } else if (result.requiresMfaEnrollment) {
// MFA enrollment required - will be handled by ProtectedLayout // MFA enrollment required - will be handled by ProtectedLayout
// Navigation happens in AuthContext // 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 // If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext
} catch (error) { } catch (error) {
@@ -128,12 +175,20 @@ export default function LoginPage() {
sessionStorage.removeItem('mfa_token'); sessionStorage.removeItem('mfa_token');
sessionStorage.removeItem('mfa_flow'); sessionStorage.removeItem('mfa_flow');
// Refresh user context and navigate // OIDC bridge: finish the flow if this is an OIDC login
if (oidcSessionId && response.token) {
await finishOidcFlow(response.token);
} else {
await refreshUser(); await refreshUser();
navigate('/profile'); navigate('/profile');
}
} else { } else {
// Fallback to regular TOTP verification // Fallback to regular TOTP verification
await verifyTotp(totpCode, useBackupCode); await verifyTotp(totpCode, useBackupCode);
if (oidcSessionId) {
const token = tokenManager.getToken();
if (token) await finishOidcFlow(token);
}
} }
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -173,7 +228,12 @@ export default function LoginPage() {
try { try {
await verifyTotp(totpCode, useBackupCode); 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) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] TOTP verification failed:", error); console.error("[Gatehouse] TOTP verification failed:", error);
@@ -229,7 +289,12 @@ export default function LoginPage() {
// Token is stored by completeLogin, refresh user and navigate // Token is stored by completeLogin, refresh user and navigate
await refreshUser(); await refreshUser();
if (oidcSessionId) {
const token = tokenManager.getToken();
if (token) await finishOidcFlow(token);
} else {
navigate('/profile'); navigate('/profile');
}
toast({ toast({
title: "Welcome back", title: "Welcome back",
@@ -295,14 +360,18 @@ export default function LoginPage() {
const formattedAssertion = formatLoginAssertion(assertion); const formattedAssertion = formatLoginAssertion(assertion);
const result = await api.webauthn.completeLogin(formattedAssertion); const result = await api.webauthn.completeLogin(formattedAssertion);
// Token is stored by completeLogin, refresh user and navigate // OIDC bridge or normal navigation
if (oidcSessionId) {
const token = tokenManager.getToken();
if (token) await finishOidcFlow(token);
} else {
await refreshUser(); await refreshUser();
navigate('/profile'); navigate('/profile');
toast({ toast({
title: "Welcome back", title: "Welcome back",
description: `Signed in as ${result.user.email}`, description: `Signed in as ${result.user.email}`,
}); });
}
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] WebAuthn verification failed:", error); console.error("[Gatehouse] WebAuthn verification failed:", error);
@@ -351,34 +420,32 @@ 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) => { const handleOAuthLogin = async (provider: OAuthProvider) => {
setIsLoading(true); setIsLoading(true);
try { try {
// Generate PKCE parameters // The redirect_uri Google will call is the *backend* callback.
const codeVerifier = generateCodeVerifier(); // The backend then redirects to the frontend /oauth/callback with the token.
const codeChallenge = await computeCodeChallenge(codeVerifier); const backendCallbackUri = `${import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'}/auth/external/${provider}/callback`;
const state = generateState();
// Store OAuth state for callback validation // Ask backend for the Google authorization URL
storeOAuthState({ // If we're in an OIDC bridge flow, pass oidc_session_id so it survives the round-trip
state, const response = await api.externalAuth.initiateLogin(provider, {
codeVerifier, redirect_uri: backendCallbackUri,
flow: 'login', flow: 'login',
provider, ...(oidcSessionId ? { oidc_session_id: oidcSessionId } : {}),
redirectUri: `${window.location.origin}/oauth/callback`,
}); });
// Get authorization URL from backend // Redirect browser to provider
const response = await api.externalAuth.initiateLogin(provider, state); 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) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -794,11 +861,16 @@ export default function LoginPage() {
<div className="auth-card"> <div className="auth-card">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-foreground tracking-tight"> <h1 className="text-2xl font-semibold text-foreground tracking-tight">
Welcome back {oidcSessionId ? "Sign in to continue" : "Welcome back"}
</h1> </h1>
<p className="text-muted-foreground mt-2"> <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> </p>
{oidcError && (
<p className="text-sm text-destructive mt-2">{oidcError}</p>
)}
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
+78 -96
View File
@@ -4,16 +4,37 @@ import { Loader2, AlertCircle, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api, ApiError, tokenManager, OAuthCallbackResponse } from "@/lib/api"; import { tokenManager } from "@/lib/api";
import { getOAuthState, clearOAuthState } from "@/lib/oauth";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
type CallbackState = 'loading' | 'success' | 'error'; 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. * OAuth callback page that handles the redirect from the Gatehouse backend
* Extracts the authorization code and state from the URL, validates the state, * after a successful (or failed) OAuth provider authentication.
* exchanges the code for tokens, and handles MFA requirements. *
* 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() { export default function OAuthCallbackPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -26,138 +47,101 @@ export default function OAuthCallbackPage() {
useEffect(() => { useEffect(() => {
const handleCallback = async () => { 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 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) { if (errorParam) {
setStatus('error'); setStatus('error');
if (errorType === 'ACCESS_DENIED' || errorParam.toLowerCase().includes('denied')) {
// User denied access
if (errorParam === 'access_denied') {
setError("You denied the authorization request. Please try again if you wish to sign in."); setError("You denied the authorization request. Please try again if you wish to sign in.");
} else { } else {
setError(errorDescription || `Authorization failed: ${errorParam}`); setError(errorParam);
} }
return; return;
} }
// Validate required parameters // Organisation selection required
if (!code || !callbackState) { if (requiresOrgSelection) {
setStatus('error'); 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; return;
} }
// 3. Validate state parameter (CSRF protection) // Organisation creation required
const storedState = getOAuthState(callbackState); if (requiresOrgCreation) {
if (!storedState) {
setStatus('error'); 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; return;
} }
try { try {
// 4. Exchange authorization code for tokens using the API const expiresAt = expiresIn
const response = await api.externalAuth.handleCallback( ? new Date(Date.now() + parseInt(expiresIn, 10) * 1000).toISOString()
storedState.provider, : null;
code,
callbackState
);
// Handle error response from backend tokenManager.setToken(token, expiresAt);
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
await refreshUser(); 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'); setStatus('success');
// Show success toast and redirect
toast({ toast({
title: "Sign in successful", 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(() => { setTimeout(() => {
switch (storedState.flowType) { switch (flowType) {
case 'link': case 'link':
navigate('/linked-accounts', { replace: true }); navigate('/linked-accounts', { replace: true });
break; break;
case 'register': case 'register':
navigate('/profile', { replace: true });
break;
case 'login': case 'login':
default: default:
navigate('/profile', { replace: true }); navigate('/profile', { replace: true });
} }
}, 1500); }, 1200);
} catch (err) { } catch (err) {
setStatus('error'); setStatus('error');
clearOAuthState(callbackState); setError("Failed to load your profile. Please try signing in again.");
if (import.meta.env.DEV) {
if (err instanceof ApiError) { console.error("[Gatehouse] OAuth callback refreshUser failed:", err);
// 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.");
} }
} }
}; };
handleCallback(); handleCallback();
}, [searchParams, navigate, refreshUser, toast]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -182,9 +166,7 @@ export default function OAuthCallbackPage() {
<AlertCircle className="w-5 h-5" /> <AlertCircle className="w-5 h-5" />
Authentication Failed Authentication Failed
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{error}</CardDescription>
{error}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button onClick={() => navigate('/login', { replace: true })} className="w-full"> <Button onClick={() => navigate('/login', { replace: true })} className="w-full">
@@ -196,7 +178,6 @@ export default function OAuthCallbackPage() {
); );
} }
// Success state (briefly shown before redirect)
return ( return (
<div className="auth-card"> <div className="auth-card">
<div className="text-center"> <div className="text-center">
@@ -209,3 +190,4 @@ export default function OAuthCallbackPage() {
</div> </div>
); );
} }
+25 -15
View File
@@ -1,21 +1,39 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { Link2, Unlink, AlertCircle, Loader2 } from "lucide-react"; import { Link2, Unlink, AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { api, LinkedAccount, ExternalProvider, ExternalProviderId, ApiError } from "@/lib/api"; import { api, LinkedAccount, ExternalProvider, ExternalProviderId, ApiError } from "@/lib/api";
import { storeOAuthState, generateState, generateCodeVerifier } from "@/lib/oauth";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
export default function LinkedAccountsPage() { export default function LinkedAccountsPage() {
const { toast } = useToast(); const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [linkedAccounts, setLinkedAccounts] = useState<LinkedAccount[]>([]); const [linkedAccounts, setLinkedAccounts] = useState<LinkedAccount[]>([]);
const [providers, setProviders] = useState<ExternalProvider[]>([]); const [providers, setProviders] = useState<ExternalProvider[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLinking, setIsLinking] = useState<ExternalProviderId | null>(null); const [isLinking, setIsLinking] = useState<ExternalProviderId | null>(null);
const [isUnlinking, setIsUnlinking] = 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(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
@@ -70,19 +88,11 @@ export default function LinkedAccountsPage() {
setIsLinking(provider); setIsLinking(provider);
try { try {
const state = generateState(); // The backend link flow also redirects to the backend callback, which
const codeVerifier = await generateCodeVerifier(); // 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, state); const response = await api.externalAuth.initiateLink(provider, backendCallbackUri);
// Store OAuth state for callback
storeOAuthState({
state,
codeVerifier,
flow: 'link',
provider,
redirectUri: `${window.location.origin}/oauth/callback`,
});
// Redirect to authorization // Redirect to authorization
window.location.href = response.authorization_url; window.location.href = response.authorization_url;
@@ -209,7 +219,7 @@ export default function LinkedAccountsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={isDisconnecting} disabled={isDisconnecting}
onClick={() => handleDisconnect(provider.id)} onClick={() => handleDisconnect(provider.id as ExternalProviderId)}
> >
{isDisconnecting ? ( {isDisconnecting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -223,7 +233,7 @@ export default function LinkedAccountsPage() {
<Button <Button
size="sm" size="sm"
disabled={!provider.is_active || isConnecting} disabled={!provider.is_active || isConnecting}
onClick={() => handleConnect(provider.id)} onClick={() => handleConnect(provider.id as ExternalProviderId)}
> >
{isConnecting ? ( {isConnecting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />