Skip to content

Commit 68c7a92

Browse files
Merge pull request #545 from DonOmalVindula/fix/nextjs-external-navigation
fix(nextjs,react): console noise on sign-out, social login and profile popup; configurable log level
2 parents 9dda2c7 + 6600eac commit 68c7a92

7 files changed

Lines changed: 181 additions & 9 deletions

File tree

‎.changeset/console-noise-fixes.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@asgardeo/nextjs': patch
3+
'@asgardeo/react': patch
4+
---
5+
6+
- Next.js: cross-origin redirects (the identity server's hosted sign-in, sign-up and logout endpoints) are now performed with a full browser navigation instead of the Next.js app router. Handing those URLs to the router made it request them as a React Server Components payload first, which the browser blocked with a CORS error before the router fell back to a normal navigation, leaving "Failed to fetch RSC payload" errors in the console on every sign-out.
7+
- React: dialogs (for example the popup mode of `<UserProfile />` opened from the user dropdown) now move focus onto the dialog when they open. Previously focus stayed on the trigger, which the focus manager had just hidden from assistive technology with `aria-hidden`, and browsers reported "Blocked aria-hidden on an element because its descendant retained focus".
8+
- Next.js: the OAuth callback handler no longer runs inside the popup opened by the embedded sign-in/sign-up flows, which logged a spurious "Authentication failed" on every social login even though the flow completed.
9+
- Next.js: the server-side log level can be raised with `ASGARDEO_LOG_LEVEL` (`debug`, `info`, `warn`, `error`; default `error`), for example to see why a profile fell back to the ID token claims.

‎packages/nextjs/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ A missing entry surfaces as Google's `Error 400: redirect_uri_mismatch`.
3232

3333
`afterSignOutUrl` (default: the app origin) is sent as the post-logout redirect URI and must be registered as well.
3434

35+
## Logging
36+
37+
The SDK logs at `error` level by default. Set `ASGARDEO_LOG_LEVEL` to `warn`, `info` or `debug` to see more,
38+
for example why a user's profile fell back to the ID token claims:
39+
40+
```bash
41+
ASGARDEO_LOG_LEVEL=warn
42+
```
43+
3544
## API Documentation
3645

3746
For complete API documentation including all components, hooks, and customization options, see the [Next.js SDK Documentation](https://wso2.com/asgardeo/docs/sdks/nextjs/overview).

‎packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import AsgardeoContext, {AsgardeoContextProps} from './AsgardeoContext';
5454
import {HttpRequestActionResult} from '../../../server/actions/httpRequestAction';
5555
import {RefreshResult} from '../../../server/actions/refreshToken';
5656
import logger from '../../../utils/logger';
57+
import navigateTo from '../../../utils/navigateTo';
5758

5859
/**
5960
* Props interface of {@link AsgardeoClientProvider}
@@ -150,6 +151,11 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
150151
// Don't handle callback if already signed in
151152
if (isSignedIn) return;
152153

154+
// The embedded sign-in/sign-up flows open the identity provider in a popup named `oauth_popup` and
155+
// read the `code`/`state` from this window's URL themselves. Handling the callback here as well would
156+
// try to exchange a code that belongs to the embedded flow and log a spurious "Authentication failed".
157+
if (typeof window !== 'undefined' && window.opener && window.name === 'oauth_popup') return;
158+
153159
(async (): Promise<void> => {
154160
try {
155161
const code: string | null = searchParams.get('code');
@@ -177,7 +183,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
177183
if (result.success) {
178184
// Redirect to the success URL
179185
if (result.redirectUrl) {
180-
router.push(result.redirectUrl);
186+
navigateTo(router, result.redirectUrl);
181187
} else {
182188
// Refresh the page to update authentication state
183189
window.location.reload();
@@ -215,14 +221,14 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
215221

216222
// Redirect based flow URL is sent as `signInUrl` in the response.
217223
if (result?.data?.signInUrl) {
218-
router.push(result.data.signInUrl);
224+
navigateTo(router, result.data.signInUrl);
219225

220226
return undefined;
221227
}
222228

223229
// After the Embedded flow is successful, the URL to navigate next is sent as `afterSignInUrl` in the response.
224230
if (result?.data?.afterSignInUrl) {
225-
router.push(result.data.afterSignInUrl);
231+
navigateTo(router, result.data.afterSignInUrl);
226232

227233
return undefined;
228234
}
@@ -251,7 +257,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
251257

252258
// Redirect based flow URL is sent as `signUpUrl` in the response.
253259
if (result?.data?.signUpUrl) {
254-
router.push(result.data.signUpUrl);
260+
navigateTo(router, result.data.signUpUrl);
255261

256262
return undefined;
257263
}
@@ -261,7 +267,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
261267
const {afterSignUpUrl, autoSignInSkippedReason, signedIn, ...flowResponse}: any = result.data;
262268

263269
// A URL passed by the caller (e.g. the `afterSignUpUrl` prop of `<SignUp />`) wins over the configured one.
264-
router.push(options?.afterSignUpUrl || afterSignUpUrl);
270+
navigateTo(router, options?.afterSignUpUrl || afterSignUpUrl);
265271

266272
if (signedIn) {
267273
// A session cookie was set during sign-up; re-render server components so the signed-in state is picked up.
@@ -295,7 +301,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
295301
logger.debug('[AsgardeoClientProvider][handleSignOut] Sign out result:', result);
296302

297303
if (result?.data?.afterSignOutUrl) {
298-
router.push(result.data.afterSignOutUrl);
304+
navigateTo(router, result.data.afterSignOutUrl);
299305

300306
return {location: result.data.afterSignOutUrl, redirected: true};
301307
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest';
20+
import navigateTo, {isCrossOriginUrl} from '../navigateTo';
21+
22+
describe('navigateTo', () => {
23+
let assign: Mock;
24+
let push: Mock;
25+
26+
beforeEach(() => {
27+
assign = vi.fn();
28+
push = vi.fn();
29+
vi.stubGlobal('window', {location: {assign, origin: 'http://localhost:3000'}});
30+
});
31+
32+
afterEach(() => {
33+
vi.unstubAllGlobals();
34+
});
35+
36+
it('uses the app router for relative and same-origin URLs', () => {
37+
navigateTo({push}, '/dashboard');
38+
navigateTo({push}, 'http://localhost:3000/profile');
39+
40+
expect(push).toHaveBeenCalledTimes(2);
41+
expect(push).toHaveBeenNthCalledWith(1, '/dashboard');
42+
expect(push).toHaveBeenNthCalledWith(2, 'http://localhost:3000/profile');
43+
expect(assign).not.toHaveBeenCalled();
44+
});
45+
46+
it('uses a full browser navigation for cross-origin URLs such as the hosted logout endpoint', () => {
47+
const logoutUrl: string =
48+
'https://api.asgardeo.io/t/acme/oidc/logout?post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A3000&state=sign_out_success';
49+
50+
navigateTo({push}, logoutUrl);
51+
52+
expect(assign).toHaveBeenCalledWith(logoutUrl);
53+
expect(push).not.toHaveBeenCalled();
54+
});
55+
56+
it('treats a different port on the same host as cross-origin', () => {
57+
expect(isCrossOriginUrl('http://localhost:3001/callback')).toBe(true);
58+
});
59+
60+
it('falls back to the router for unparsable values', () => {
61+
navigateTo({push}, 'http://[invalid');
62+
63+
expect(push).toHaveBeenCalledWith('http://[invalid');
64+
expect(assign).not.toHaveBeenCalled();
65+
});
66+
});

‎packages/nextjs/src/utils/logger.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,23 @@
1616
* under the License.
1717
*/
1818

19-
import {createLogger} from '@asgardeo/node';
19+
import {createLogger, LogLevel} from '@asgardeo/node';
20+
21+
const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error'];
22+
23+
/**
24+
* Resolves the server-side log level from `ASGARDEO_LOG_LEVEL`. Defaults to `error`, so that
25+
* degraded-but-working situations the SDK reports at `warn` (for example falling back to the ID token
26+
* when the SCIM2 profile cannot be loaded) can be surfaced by setting `ASGARDEO_LOG_LEVEL=warn`.
27+
*/
28+
const resolveLogLevel = (): LogLevel => {
29+
const configured: string | undefined = process.env['ASGARDEO_LOG_LEVEL']?.toLowerCase();
30+
31+
return LOG_LEVELS.includes(configured as LogLevel) ? (configured as LogLevel) : 'error';
32+
};
2033

2134
const logger: any = createLogger({
22-
level: 'error',
35+
level: resolveLogLevel(),
2336
});
2437

2538
export default logger;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
/**
20+
* The subset of the Next.js app router used for in-app navigation.
21+
*/
22+
export interface InAppRouter {
23+
push: (href: string) => void;
24+
}
25+
26+
/**
27+
* Whether `url` points outside the current origin (e.g. the identity server's hosted
28+
* login or logout endpoint). Relative URLs and same-origin URLs return `false`.
29+
*/
30+
export const isCrossOriginUrl = (url: string): boolean => {
31+
if (typeof window === 'undefined') {
32+
return false;
33+
}
34+
35+
try {
36+
return new URL(url, window.location.origin).origin !== window.location.origin;
37+
} catch {
38+
return false;
39+
}
40+
};
41+
42+
/**
43+
* Navigates to `url`, using the Next.js router for in-app URLs and a full browser
44+
* navigation for cross-origin ones.
45+
*
46+
* The app router can only render routes of this application: handing it an external
47+
* URL makes it request that URL as a React Server Components payload first, which the
48+
* browser blocks (CORS) before the router falls back to a normal navigation, leaving
49+
* "Failed to fetch RSC payload" errors in the console on every hosted sign-in or sign-out.
50+
*/
51+
const navigateTo = (router: InAppRouter, url: string): void => {
52+
if (isCrossOriginUrl(url)) {
53+
window.location.assign(url);
54+
55+
return;
56+
}
57+
58+
router.push(url);
59+
};
60+
61+
export default navigateTo;

‎packages/react/src/components/primitives/Dialog/Dialog.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,17 @@ export const DialogContent: ForwardRefExoticComponent<HTMLProps<HTMLDivElement>
188188
className={cx(withVendorCSSClassPrefix(bem('dialog', 'overlay')), styles['overlay'])}
189189
lockScroll
190190
>
191-
<FloatingFocusManager context={floatingContext} initialFocus={-1}>
191+
{/*
192+
Move focus onto the dialog itself when it opens. The focus manager marks everything outside the
193+
dialog `aria-hidden`, so leaving focus on the trigger (e.g. the user dropdown in a page header)
194+
hides a focused element from assistive technology, which browsers flag as an accessibility error.
195+
Focusing the container rather than the first field keeps the previous behaviour of not
196+
auto-focusing an input.
197+
*/}
198+
<FloatingFocusManager context={floatingContext} initialFocus={context.refs.floating}>
192199
<div
193200
ref={ref}
201+
tabIndex={-1}
194202
className={cx(withVendorCSSClassPrefix(bem('dialog', 'content')), styles['content'], props.className)}
195203
aria-labelledby={context.labelId}
196204
aria-describedby={context.descriptionId}

0 commit comments

Comments
 (0)