Skip to content

Commit 85929eb

Browse files
authored
Merge pull request asgardeo#367 from thiva-k/add-oauth-callback
Add external OAuth callback support for V2
2 parents fbc95de + 122b58b commit 85929eb

13 files changed

Lines changed: 739 additions & 78 deletions

File tree

.changeset/plain-oranges-beg.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@asgardeo/react-router': minor
3+
'@asgardeo/react': minor
4+
---
5+
6+
Add oauth callback support for v2

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"tmp": "0.2.4",
6666
"min-document": "2.19.1",
6767
"lodash": "4.17.23",
68-
"tar": "7.5.7",
68+
"tar": "7.5.8",
6969
"seroval": "1.4.1",
7070
"qs": "6.14.1",
7171
"@vitejs/plugin-vue>vite": "7.1.12",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Copyright (c) 2026, 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 {Callback} from '@asgardeo/react';
20+
import {FC} from 'react';
21+
import {useLocation, useNavigate} from 'react-router';
22+
23+
/**
24+
* Props for the CallbackRoute component.
25+
*/
26+
export interface CallbackRouteProps {
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={<CallbackRoute />} />
50+
* ```
51+
*/
52+
const CallbackRoute: FC<CallbackRouteProps> = ({onError, onNavigate}: CallbackRouteProps) => {
53+
const navigate: ReturnType<typeof useNavigate> = useNavigate();
54+
const location: ReturnType<typeof useLocation> = useLocation();
55+
56+
const handleNavigate = (path: string): void => {
57+
if (onNavigate) {
58+
onNavigate(path);
59+
return;
60+
}
61+
62+
const fullPath: string = window.location.pathname;
63+
const relativePath: string = location.pathname;
64+
const basename: string = fullPath.endsWith(relativePath)
65+
? fullPath.slice(0, -relativePath.length).replace(/\/$/, '')
66+
: '';
67+
68+
const navigationPath: string = basename && path.startsWith(basename) ? path.slice(basename.length) || '/' : path;
69+
70+
navigate(navigationPath);
71+
};
72+
73+
return (
74+
<Callback
75+
onNavigate={handleNavigate}
76+
onError={
77+
onError ||
78+
((error: Error): void => {
79+
// eslint-disable-next-line no-console
80+
console.error('OAuth callback error:', error);
81+
})
82+
}
83+
/>
84+
);
85+
};
86+
87+
export default CallbackRoute;

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 CallbackRoute} from './components/CallbackRoute';
23+
export * from './components/CallbackRoute';
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Copyright (c) 2026, 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 {navigate as browserNavigate} from '@asgardeo/browser';
20+
import {FC, useEffect, useRef} from 'react';
21+
22+
/**
23+
* Props for Callback component
24+
*/
25+
export interface CallbackProps {
26+
/**
27+
* Callback function called when an error occurs
28+
*/
29+
onError?: (error: Error) => void;
30+
31+
/**
32+
* Function to navigate to a different path.
33+
* If not provided, falls back to the browser navigate utility (SPA navigation via History API for same-origin paths).
34+
* Provide this prop to enable framework-specific navigation (e.g., from React Router).
35+
*/
36+
onNavigate?: (path: string) => void;
37+
}
38+
39+
/**
40+
* BaseCallback is a headless component that handles OAuth callback parameter forwarding.
41+
* This component extracts OAuth parameters (code, state, error) from the URL and forwards them
42+
* to the original component that initiated the OAuth flow.
43+
*
44+
* Works standalone using the browser navigate utility (History API) for navigation by default.
45+
* Pass an onNavigate prop to enable framework-specific navigation (e.g., via React Router).
46+
*
47+
* Flow: Extract OAuth parameters from URL -> Parse state parameter -> Redirect to original path with parameters
48+
*
49+
* The original component (SignIn/AcceptInvite) is responsible for:
50+
* - Processing the OAuth code via the SDK
51+
* - Calling /flow/execute
52+
* - Handling the assertion and auth/callback POST
53+
* - Managing the authenticated session
54+
*/
55+
export const Callback: FC<CallbackProps> = ({onNavigate, onError}: CallbackProps) => {
56+
// Prevent double execution in React Strict Mode
57+
const processingRef: any = useRef(false);
58+
59+
// Resolve navigation: use provided onNavigate (router-aware) or fall back to browser navigate utility
60+
const navigate = (path: string): void => {
61+
if (onNavigate) {
62+
onNavigate(path);
63+
} else {
64+
browserNavigate(path);
65+
}
66+
};
67+
68+
useEffect(() => {
69+
const processOAuthCallback = (): void => {
70+
// Guard against double execution
71+
if (processingRef.current) {
72+
return;
73+
}
74+
processingRef.current = true;
75+
76+
// Declare variables outside try block for use in catch
77+
let returnPath: string = '/';
78+
79+
try {
80+
// 1. Extract OAuth parameters from URL
81+
const urlParams: URLSearchParams = new URLSearchParams(window.location.search);
82+
const code: string | null = urlParams.get('code');
83+
const state: string | null = urlParams.get('state');
84+
const nonce: string | null = urlParams.get('nonce');
85+
const oauthError: string | null = urlParams.get('error');
86+
const errorDescription: string | null = urlParams.get('error_description');
87+
88+
// 2. Validate and retrieve OAuth state from sessionStorage
89+
if (!state) {
90+
throw new Error('Missing OAuth state parameter - possible security issue');
91+
}
92+
93+
const storedData: string | null = sessionStorage.getItem(`asgardeo_oauth_${state}`);
94+
if (!storedData) {
95+
// If state not found, might be an error callback - try to handle gracefully
96+
if (oauthError) {
97+
const errorMsg: string = errorDescription || oauthError || 'OAuth authentication failed';
98+
const err: Error = new Error(errorMsg);
99+
onError?.(err);
100+
101+
const params: URLSearchParams = new URLSearchParams();
102+
params.set('error', oauthError);
103+
if (errorDescription) {
104+
params.set('error_description', errorDescription);
105+
}
106+
107+
navigate(`/?${params.toString()}`);
108+
return;
109+
}
110+
throw new Error('Invalid OAuth state - possible CSRF attack');
111+
}
112+
113+
const {path, timestamp} = JSON.parse(storedData);
114+
returnPath = path || '/';
115+
116+
// 3. Validate state freshness
117+
const MAX_STATE_AGE: number = 600000; // 10 minutes
118+
if (Date.now() - timestamp > MAX_STATE_AGE) {
119+
sessionStorage.removeItem(`asgardeo_oauth_${state}`);
120+
throw new Error('OAuth state expired - please try again');
121+
}
122+
123+
// 4. Clean up state
124+
sessionStorage.removeItem(`asgardeo_oauth_${state}`);
125+
126+
// 5. Handle OAuth error response
127+
if (oauthError) {
128+
const errorMsg: string = errorDescription || oauthError || 'OAuth authentication failed';
129+
const err: Error = new Error(errorMsg);
130+
onError?.(err);
131+
132+
const params: URLSearchParams = new URLSearchParams();
133+
params.set('error', oauthError);
134+
if (errorDescription) {
135+
params.set('error_description', errorDescription);
136+
}
137+
138+
navigate(`${returnPath}?${params.toString()}`);
139+
return;
140+
}
141+
142+
// 6. Validate required parameters
143+
if (!code) {
144+
throw new Error('Missing OAuth authorization code');
145+
}
146+
147+
// 7. Forward OAuth code to original component
148+
// The component (SignIn/AcceptInvite) will retrieve flowId/authId from sessionStorage
149+
const params: URLSearchParams = new URLSearchParams();
150+
params.set('code', code);
151+
if (nonce) {
152+
params.set('nonce', nonce);
153+
}
154+
155+
navigate(`${returnPath}?${params.toString()}`);
156+
} catch (err) {
157+
const errorMessage: string = err instanceof Error ? err.message : 'OAuth callback processing failed';
158+
// eslint-disable-next-line no-console
159+
console.error('OAuth callback error:', err);
160+
161+
onError?.(err instanceof Error ? err : new Error(errorMessage));
162+
163+
// Redirect back with OAuth error format
164+
const params: URLSearchParams = new URLSearchParams();
165+
params.set('error', 'callback_error');
166+
params.set('error_description', errorMessage);
167+
168+
navigate(`${returnPath}?${params.toString()}`);
169+
}
170+
};
171+
172+
processOAuthCallback();
173+
}, [onNavigate, onError]);
174+
175+
// Headless component - no UI, just processing logic
176+
return null;
177+
};
178+
179+
export default Callback;

0 commit comments

Comments
 (0)