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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
*.env
|
||||||
Generated
+17
-1
@@ -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
@@ -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) =>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user