Skip to content

Commit 8207013

Browse files
committed
Add oauth callback
1 parent fbc95de commit 8207013

8 files changed

Lines changed: 614 additions & 59 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {FC} from 'react';
20+
import {useNavigate, useLocation} from 'react-router';
21+
import {BaseCallback} from '@asgardeo/react';
22+
23+
/**
24+
* Props for the Callback component.
25+
*/
26+
export interface CallbackProps {
27+
/**
28+
* Callback function called when an error occurs during OAuth processing.
29+
* @param error - The error that occurred
30+
*/
31+
onError?: (error: Error) => void;
32+
33+
/**
34+
* Optional custom navigation handler.
35+
* If provided, this will be called instead of the default navigate() behavior.
36+
* Useful for apps that need custom navigation logic.
37+
* @param path - The path to navigate to
38+
*/
39+
onNavigate?: (path: string) => void;
40+
}
41+
42+
/**
43+
* Handles OAuth callback redirects for React Router applications.
44+
* Processes authorization code, validates CSRF state, and navigates back to the original path.
45+
* Automatically handles React Router basename when configured.
46+
*
47+
* @example
48+
* ```tsx
49+
* <Route path="/callback" element={<Callback />} />
50+
* ```
51+
*/
52+
const Callback: FC<CallbackProps> = ({
53+
onError,
54+
onNavigate,
55+
}) => {
56+
const navigate = useNavigate();
57+
const location = useLocation();
58+
59+
const handleNavigate = (path: string): void => {
60+
if (onNavigate) {
61+
onNavigate(path);
62+
return;
63+
}
64+
65+
const fullPath = window.location.pathname;
66+
const relativePath = location.pathname;
67+
const basename = fullPath.endsWith(relativePath)
68+
? fullPath.slice(0, -relativePath.length).replace(/\/$/, '')
69+
: '';
70+
71+
const navigationPath =
72+
basename && path.startsWith(basename) ? path.slice(basename.length) || '/' : path;
73+
74+
navigate(navigationPath);
75+
};
76+
77+
return (
78+
<BaseCallback
79+
onNavigate={handleNavigate}
80+
onError={
81+
onError ||
82+
((error: Error) => {
83+
// eslint-disable-next-line no-console
84+
console.error('OAuth callback error:', error);
85+
})
86+
}
87+
/>
88+
);
89+
};
90+
91+
export default Callback;

packages/react-router/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@
1818

1919
export {default as ProtectedRoute} from './components/ProtectedRoute';
2020
export * from './components/ProtectedRoute';
21+
22+
export {default as Callback} from './components/Callback';
23+
export * from './components/Callback';
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {FC, useEffect, useRef} from 'react';
20+
21+
/**
22+
* Props for BaseCallback component
23+
*/
24+
export interface BaseCallbackProps {
25+
/**
26+
* Function to navigate to a different path
27+
*/
28+
onNavigate: (path: string) => void;
29+
30+
/**
31+
* Callback function called when an error occurs
32+
*/
33+
onError?: (error: Error) => void;
34+
}
35+
36+
/**
37+
* BaseCallback is a headless component that handles OAuth callback parameter forwarding.
38+
* This component extracts OAuth parameters (code, state, error) from the URL and forwards them
39+
* to the original component that initiated the OAuth flow.
40+
*
41+
* This component is framework-agnostic and should be wrapped by framework-specific
42+
* implementations that provide navigation functions.
43+
*
44+
* Flow: Extract OAuth parameters from URL -> Parse state parameter -> Redirect to original path with parameters
45+
*
46+
* The original component (SignIn/AcceptInvite) is responsible for:
47+
* - Processing the OAuth code via the SDK
48+
* - Calling /flow/execute
49+
* - Handling the assertion and auth/callback POST
50+
* - Managing the authenticated session
51+
*/
52+
export const BaseCallback: FC<BaseCallbackProps> = ({
53+
onNavigate,
54+
onError,
55+
}) => {
56+
// Prevent double execution in React Strict Mode
57+
const processingRef = useRef(false);
58+
59+
useEffect(() => {
60+
const processOAuthCallback = (): void => {
61+
// Guard against double execution
62+
if (processingRef.current) {
63+
return;
64+
}
65+
processingRef.current = true;
66+
67+
// Declare variables outside try block for use in catch
68+
var returnPath = '/';
69+
70+
try {
71+
// Extract OAuth parameters from URL
72+
const urlParams = new URLSearchParams(window.location.search);
73+
const code = urlParams.get('code');
74+
const state = urlParams.get('state');
75+
const nonce = urlParams.get('nonce');
76+
const oauthError = urlParams.get('error');
77+
const errorDescription = urlParams.get('error_description');
78+
79+
// Validate and retrieve OAuth state from sessionStorage
80+
if (!state) {
81+
throw new Error('Missing OAuth state parameter - possible security issue');
82+
}
83+
84+
const storedData = sessionStorage.getItem(`asgardeo_oauth_${state}`);
85+
if (!storedData) {
86+
// If state not found, might be an error callback - try to handle gracefully
87+
if (oauthError) {
88+
const errorMsg = errorDescription || oauthError || 'OAuth authentication failed';
89+
const err = new Error(errorMsg);
90+
onError?.(err);
91+
92+
const params = new URLSearchParams();
93+
params.set('error', oauthError);
94+
if (errorDescription) {
95+
params.set('error_description', errorDescription);
96+
}
97+
98+
onNavigate(`/?${params.toString()}`);
99+
return;
100+
}
101+
throw new Error('Invalid OAuth state - possible CSRF attack');
102+
}
103+
104+
const {path, timestamp} = JSON.parse(storedData);
105+
returnPath = path || '/';
106+
107+
// Validate state freshness
108+
const MAX_STATE_AGE = 600000; // 10 minutes
109+
if (Date.now() - timestamp > MAX_STATE_AGE) {
110+
sessionStorage.removeItem(`asgardeo_oauth_${state}`);
111+
throw new Error('OAuth state expired - please try again');
112+
}
113+
114+
// Clean up state
115+
sessionStorage.removeItem(`asgardeo_oauth_${state}`);
116+
117+
// Handle OAuth error response
118+
if (oauthError) {
119+
const errorMsg = errorDescription || oauthError || 'OAuth authentication failed';
120+
const err = new Error(errorMsg);
121+
onError?.(err);
122+
123+
const params = new URLSearchParams();
124+
params.set('error', oauthError);
125+
if (errorDescription) {
126+
params.set('error_description', errorDescription);
127+
}
128+
129+
onNavigate(`${returnPath}?${params.toString()}`);
130+
return;
131+
}
132+
133+
// Validate required parameters
134+
if (!code) {
135+
throw new Error('Missing OAuth authorization code');
136+
}
137+
138+
// Forward OAuth code to original component
139+
// The component (SignIn/AcceptInvite) will retrieve flowId/authId from sessionStorage
140+
const params = new URLSearchParams();
141+
params.set('code', code);
142+
if (nonce) {
143+
params.set('nonce', nonce);
144+
}
145+
146+
onNavigate(`${returnPath}?${params.toString()}`);
147+
} catch (err) {
148+
const errorMessage = err instanceof Error ? err.message : 'OAuth callback processing failed';
149+
console.error('OAuth callback error:', err);
150+
151+
onError?.(err instanceof Error ? err : new Error(errorMessage));
152+
153+
// Redirect back with OAuth error format
154+
const params = new URLSearchParams();
155+
params.set('error', 'callback_error');
156+
params.set('error_description', errorMessage);
157+
158+
onNavigate(`${returnPath}?${params.toString()}`);
159+
}
160+
};
161+
162+
processOAuthCallback();
163+
}, [onNavigate, onError]);
164+
165+
// Headless component - no UI, just processing logic
166+
return null;
167+
};
168+
169+
export default BaseCallback;

packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {cx} from '@emotion/css';
2020
import {FC, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react';
2121
import useStyles from './BaseAcceptInvite.styles';
2222
import useTheme from '../../../../../contexts/Theme/useTheme';
23+
import {useOAuthCallback} from '../../../../../hooks/useOAuthCallback';
2324
import useTranslation from '../../../../../hooks/useTranslation';
2425
import {normalizeFlowResponse, extractErrorMessage} from '../../../../../utils/v2/flowTransformer';
26+
import {initiateOAuthRedirect} from '../../../../../utils/oauth';
2527
import AlertPrimitive from '../../../../primitives/Alert/Alert';
2628
import Button from '../../../../primitives/Button/Button';
2729
// eslint-disable-next-line import/no-named-as-default
@@ -40,6 +42,7 @@ export interface AcceptInviteFlowResponse {
4042
meta?: {
4143
components?: any[];
4244
};
45+
redirectURL?: string;
4346
};
4447
failureReason?: string;
4548
flowId: string;
@@ -285,6 +288,44 @@ const BaseAcceptInvite: FC<BaseAcceptInviteProps> = ({
285288
[t, onError],
286289
);
287290

291+
/**
292+
* Handle OAuth callback when returning from OAuth provider.
293+
* This hook processes the authorization code and continues the flow.
294+
*/
295+
useOAuthCallback({
296+
onSubmit: async (payload: any) => {
297+
const rawResponse: any = await onSubmit(payload);
298+
const response: any = normalizeFlowResponseLocal(rawResponse);
299+
return response;
300+
},
301+
onComplete: () => {
302+
setIsComplete(true);
303+
setIsValidatingToken(false);
304+
onComplete?.();
305+
},
306+
onError: (error: any) => {
307+
setIsTokenInvalid(true);
308+
setIsValidatingToken(false);
309+
handleError(error);
310+
},
311+
onProcessingStart: () => {
312+
setIsValidatingToken(true);
313+
},
314+
currentFlowId: flowId ?? null,
315+
isInitialized: true,
316+
tokenValidationAttemptedRef,
317+
onFlowChange: (response: any) => {
318+
onFlowChange?.(response);
319+
// Initialize currentFlow for next steps if not complete
320+
if (response.flowStatus !== 'COMPLETE') {
321+
setCurrentFlow(response);
322+
setFormValues({});
323+
setFormErrors({});
324+
setTouchedFields({});
325+
}
326+
},
327+
});
328+
288329
/**
289330
* Normalize flow response to ensure component-driven format.
290331
* Transforms data.meta.components to data.components.
@@ -416,6 +457,16 @@ const BaseAcceptInvite: FC<BaseAcceptInviteProps> = ({
416457
const response: any = normalizeFlowResponseLocal(rawResponse);
417458
onFlowChange?.(response);
418459

460+
// Handle OAuth redirect response
461+
if (response.type === 'REDIRECTION') {
462+
const redirectURL: any = response.data?.redirectURL || (response as any)?.redirectURL;
463+
if (redirectURL && typeof window !== 'undefined') {
464+
// Initiate OAuth redirect with secure state management
465+
initiateOAuthRedirect(redirectURL);
466+
return;
467+
}
468+
}
469+
419470
// Store the heading from current flow before completion
420471
if (currentFlow?.data?.components || currentFlow?.data?.meta?.components) {
421472
const currentComponents: any = currentFlow.data.components || currentFlow.data.meta?.components || [];
@@ -467,12 +518,16 @@ const BaseAcceptInvite: FC<BaseAcceptInviteProps> = ({
467518
* Validate invite token on component mount.
468519
*/
469520
useEffect(() => {
470-
if (!flowId || !inviteToken || tokenValidationAttemptedRef.current) {
471-
if (!flowId || !inviteToken) {
472-
setIsValidatingToken(false);
473-
setIsTokenInvalid(true);
474-
handleError(new Error('Invalid invite link. Missing flowId or inviteToken.'));
475-
}
521+
// Skip validation if already validated
522+
if (tokenValidationAttemptedRef.current) {
523+
return;
524+
}
525+
526+
// Validate required params for initial invite link
527+
if (!flowId || !inviteToken) {
528+
setIsValidatingToken(false);
529+
setIsTokenInvalid(true);
530+
handleError(new Error('Invalid invite link. Missing flowId or inviteToken.'));
476531
return;
477532
}
478533

@@ -483,6 +538,11 @@ const BaseAcceptInvite: FC<BaseAcceptInviteProps> = ({
483538
setApiError(null);
484539

485540
try {
541+
// Store flowId in sessionStorage for OAuth callback
542+
if (flowId) {
543+
sessionStorage.setItem('asgardeo_flow_id', flowId);
544+
}
545+
486546
// Send the invite token to validate and continue the flow
487547
const payload: any = {
488548
flowId,

0 commit comments

Comments
 (0)