Skip to content

Commit 78626c7

Browse files
committed
Add oauth callback
1 parent fbc95de commit 78626c7

9 files changed

Lines changed: 668 additions & 113 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 = basename && path.startsWith(basename)
72+
? path.slice(basename.length) || '/'
73+
: path;
74+
75+
navigate(navigationPath);
76+
};
77+
78+
return (
79+
<BaseCallback
80+
onNavigate={handleNavigate}
81+
onError={onError || ((error: Error) => {
82+
// eslint-disable-next-line no-console
83+
console.error('OAuth callback error:', error);
84+
})}
85+
/>
86+
);
87+
};
88+
89+
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+
// 1. 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+
// 2. 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+
// 3. 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+
// 4. Clean up state
115+
sessionStorage.removeItem(`asgardeo_oauth_${state}`);
116+
117+
// 5. 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+
// 6. Validate required parameters
134+
if (!code) {
135+
throw new Error('Missing OAuth authorization code');
136+
}
137+
138+
// 7. 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/AcceptInvite.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -184,22 +184,22 @@ const AcceptInvite: FC<AcceptInviteProps> = ({
184184
return '';
185185
}, [baseUrl]);
186186

187-
/**
188-
* Submit flow step data.
189-
* Makes an unauthenticated request to /flow/execute endpoint.
190-
*/
191-
const handleSubmit = async (payload: Record<string, any>): Promise<AcceptInviteFlowResponse> => {
192-
const response: any = await fetch(`${apiBaseUrl}/flow/execute`, {
193-
body: JSON.stringify({
194-
...payload,
195-
verbose: true,
196-
}),
197-
headers: {
198-
Accept: 'application/json',
199-
'Content-Type': 'application/json',
200-
},
201-
method: 'POST',
202-
});
187+
/**
188+
* Submit flow step data.
189+
* Makes an unauthenticated request to /flow/execute endpoint.
190+
*/
191+
const handleSubmit = async (payload: Record<string, any>): Promise<AcceptInviteFlowResponse> => {
192+
const response = await fetch(`${apiBaseUrl}/flow/execute`, {
193+
method: 'POST',
194+
headers: {
195+
'Content-Type': 'application/json',
196+
Accept: 'application/json',
197+
},
198+
body: JSON.stringify({
199+
...payload,
200+
verbose: true,
201+
}),
202+
});
203203

204204
if (!response.ok) {
205205
const errorText: any = await response.text();

0 commit comments

Comments
 (0)