From df86beedb416110606104468a78ec0473068fa49 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sat, 5 Sep 2026 22:53:22 +0530 Subject: [PATCH 1/4] fix(nextjs): use a browser navigation for cross-origin redirects The client provider handed every redirect to the Next.js app router, including the identity server's hosted sign-in, sign-up and logout URLs. The router can only render routes of this application, so for those it first requested the URL as a React Server Components payload, which the browser blocked with a CORS error, and only then fell back to a normal navigation. Sign-out therefore worked but logged "Failed to fetch RSC payload" and CORS errors every time. Route the redirects through a small navigateTo helper that uses window.location.assign for cross-origin URLs and the router for in-app ones. --- .changeset/nextjs-external-navigation.md | 5 ++ .../contexts/Asgardeo/AsgardeoProvider.tsx | 13 ++-- .../src/utils/__tests__/navigateTo.test.ts | 66 +++++++++++++++++++ packages/nextjs/src/utils/navigateTo.ts | 61 +++++++++++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 .changeset/nextjs-external-navigation.md create mode 100644 packages/nextjs/src/utils/__tests__/navigateTo.test.ts create mode 100644 packages/nextjs/src/utils/navigateTo.ts diff --git a/.changeset/nextjs-external-navigation.md b/.changeset/nextjs-external-navigation.md new file mode 100644 index 000000000..fe49b7c49 --- /dev/null +++ b/.changeset/nextjs-external-navigation.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +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. diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index e6640ca3d..f96b8cbf3 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -54,6 +54,7 @@ import AsgardeoContext, {AsgardeoContextProps} from './AsgardeoContext'; import {HttpRequestActionResult} from '../../../server/actions/httpRequestAction'; import {RefreshResult} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; +import navigateTo from '../../../utils/navigateTo'; /** * Props interface of {@link AsgardeoClientProvider} @@ -177,7 +178,7 @@ const AsgardeoClientProvider: FC> if (result.success) { // Redirect to the success URL if (result.redirectUrl) { - router.push(result.redirectUrl); + navigateTo(router, result.redirectUrl); } else { // Refresh the page to update authentication state window.location.reload(); @@ -215,14 +216,14 @@ const AsgardeoClientProvider: FC> // Redirect based flow URL is sent as `signInUrl` in the response. if (result?.data?.signInUrl) { - router.push(result.data.signInUrl); + navigateTo(router, result.data.signInUrl); return undefined; } // After the Embedded flow is successful, the URL to navigate next is sent as `afterSignInUrl` in the response. if (result?.data?.afterSignInUrl) { - router.push(result.data.afterSignInUrl); + navigateTo(router, result.data.afterSignInUrl); return undefined; } @@ -251,7 +252,7 @@ const AsgardeoClientProvider: FC> // Redirect based flow URL is sent as `signUpUrl` in the response. if (result?.data?.signUpUrl) { - router.push(result.data.signUpUrl); + navigateTo(router, result.data.signUpUrl); return undefined; } @@ -261,7 +262,7 @@ const AsgardeoClientProvider: FC> const {afterSignUpUrl, autoSignInSkippedReason, signedIn, ...flowResponse}: any = result.data; // A URL passed by the caller (e.g. the `afterSignUpUrl` prop of ``) wins over the configured one. - router.push(options?.afterSignUpUrl || afterSignUpUrl); + navigateTo(router, options?.afterSignUpUrl || afterSignUpUrl); if (signedIn) { // A session cookie was set during sign-up; re-render server components so the signed-in state is picked up. @@ -295,7 +296,7 @@ const AsgardeoClientProvider: FC> logger.debug('[AsgardeoClientProvider][handleSignOut] Sign out result:', result); if (result?.data?.afterSignOutUrl) { - router.push(result.data.afterSignOutUrl); + navigateTo(router, result.data.afterSignOutUrl); return {location: result.data.afterSignOutUrl, redirected: true}; } diff --git a/packages/nextjs/src/utils/__tests__/navigateTo.test.ts b/packages/nextjs/src/utils/__tests__/navigateTo.test.ts new file mode 100644 index 000000000..89133ba5e --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/navigateTo.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import navigateTo, {isCrossOriginUrl} from '../navigateTo'; + +describe('navigateTo', () => { + let assign: Mock; + let push: Mock; + + beforeEach(() => { + assign = vi.fn(); + push = vi.fn(); + vi.stubGlobal('window', {location: {assign, origin: 'http://localhost:3000'}}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses the app router for relative and same-origin URLs', () => { + navigateTo({push}, '/dashboard'); + navigateTo({push}, 'http://localhost:3000/profile'); + + expect(push).toHaveBeenCalledTimes(2); + expect(push).toHaveBeenNthCalledWith(1, '/dashboard'); + expect(push).toHaveBeenNthCalledWith(2, 'http://localhost:3000/profile'); + expect(assign).not.toHaveBeenCalled(); + }); + + it('uses a full browser navigation for cross-origin URLs such as the hosted logout endpoint', () => { + const logoutUrl: string = + 'https://api.asgardeo.io/t/acme/oidc/logout?post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A3000&state=sign_out_success'; + + navigateTo({push}, logoutUrl); + + expect(assign).toHaveBeenCalledWith(logoutUrl); + expect(push).not.toHaveBeenCalled(); + }); + + it('treats a different port on the same host as cross-origin', () => { + expect(isCrossOriginUrl('http://localhost:3001/callback')).toBe(true); + }); + + it('falls back to the router for unparsable values', () => { + navigateTo({push}, 'http://[invalid'); + + expect(push).toHaveBeenCalledWith('http://[invalid'); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/utils/navigateTo.ts b/packages/nextjs/src/utils/navigateTo.ts new file mode 100644 index 000000000..fd13c32b2 --- /dev/null +++ b/packages/nextjs/src/utils/navigateTo.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The subset of the Next.js app router used for in-app navigation. + */ +export interface InAppRouter { + push: (href: string) => void; +} + +/** + * Whether `url` points outside the current origin (e.g. the identity server's hosted + * login or logout endpoint). Relative URLs and same-origin URLs return `false`. + */ +export const isCrossOriginUrl = (url: string): boolean => { + if (typeof window === 'undefined') { + return false; + } + + try { + return new URL(url, window.location.origin).origin !== window.location.origin; + } catch { + return false; + } +}; + +/** + * Navigates to `url`, using the Next.js router for in-app URLs and a full browser + * navigation for cross-origin ones. + * + * The app router can only render routes of this application: handing it an external + * URL makes it request that URL as a React Server Components payload first, which the + * browser blocks (CORS) before the router falls back to a normal navigation, leaving + * "Failed to fetch RSC payload" errors in the console on every hosted sign-in or sign-out. + */ +const navigateTo = (router: InAppRouter, url: string): void => { + if (isCrossOriginUrl(url)) { + window.location.assign(url); + + return; + } + + router.push(url); +}; + +export default navigateTo; From dc46d98640d10b8aeb4e057ea32fbe723dc2e8b0 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sat, 5 Sep 2026 22:56:37 +0530 Subject: [PATCH 2/4] fix(react): focus the dialog when it opens instead of leaving focus on the hidden trigger FloatingFocusManager marks everything outside an open dialog aria-hidden. With initialFocus={-1} the focus stayed on the element that opened the dialog, e.g. the user dropdown trigger in a page header, so a focused element was hidden from assistive technology and Chrome logged "Blocked aria-hidden on an element because its descendant retained focus" whenever the profile popup opened. Focus the dialog container itself on open. This keeps the previous behaviour of not auto-focusing the first input. --- .changeset/dialog-initial-focus.md | 5 +++++ .../react/src/components/primitives/Dialog/Dialog.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/dialog-initial-focus.md diff --git a/.changeset/dialog-initial-focus.md b/.changeset/dialog-initial-focus.md new file mode 100644 index 000000000..271cb2213 --- /dev/null +++ b/.changeset/dialog-initial-focus.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/react': patch +--- + +Dialogs (for example the popup mode of `` 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". diff --git a/packages/react/src/components/primitives/Dialog/Dialog.tsx b/packages/react/src/components/primitives/Dialog/Dialog.tsx index db65685e5..dd2338d96 100644 --- a/packages/react/src/components/primitives/Dialog/Dialog.tsx +++ b/packages/react/src/components/primitives/Dialog/Dialog.tsx @@ -188,9 +188,17 @@ export const DialogContent: ForwardRefExoticComponent className={cx(withVendorCSSClassPrefix(bem('dialog', 'overlay')), styles['overlay'])} lockScroll > - + {/* + Move focus onto the dialog itself when it opens. The focus manager marks everything outside the + dialog `aria-hidden`, so leaving focus on the trigger (e.g. the user dropdown in a page header) + hides a focused element from assistive technology, which browsers flag as an accessibility error. + Focusing the container rather than the first field keeps the previous behaviour of not + auto-focusing an input. + */} +
Date: Sat, 5 Sep 2026 22:58:35 +0530 Subject: [PATCH 3/4] chore: single changeset for the sign-out navigation and dialog focus fixes --- .changeset/console-noise-fixes.md | 7 +++++++ .changeset/dialog-initial-focus.md | 5 ----- .changeset/nextjs-external-navigation.md | 5 ----- 3 files changed, 7 insertions(+), 10 deletions(-) create mode 100644 .changeset/console-noise-fixes.md delete mode 100644 .changeset/dialog-initial-focus.md delete mode 100644 .changeset/nextjs-external-navigation.md diff --git a/.changeset/console-noise-fixes.md b/.changeset/console-noise-fixes.md new file mode 100644 index 000000000..8505d3630 --- /dev/null +++ b/.changeset/console-noise-fixes.md @@ -0,0 +1,7 @@ +--- +'@asgardeo/nextjs': patch +'@asgardeo/react': patch +--- + +- 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. +- React: dialogs (for example the popup mode of `` 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". diff --git a/.changeset/dialog-initial-focus.md b/.changeset/dialog-initial-focus.md deleted file mode 100644 index 271cb2213..000000000 --- a/.changeset/dialog-initial-focus.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@asgardeo/react': patch ---- - -Dialogs (for example the popup mode of `` 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". diff --git a/.changeset/nextjs-external-navigation.md b/.changeset/nextjs-external-navigation.md deleted file mode 100644 index fe49b7c49..000000000 --- a/.changeset/nextjs-external-navigation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@asgardeo/nextjs': patch ---- - -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. From 6600eac47654c464126f68ffb1c3f453bc3af215 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sat, 5 Sep 2026 23:01:08 +0530 Subject: [PATCH 4/4] fix(nextjs): skip the OAuth callback inside the embedded-flow popup; make the log level configurable - The embedded sign-in/sign-up flows open the identity provider in a popup named oauth_popup and read code/state from that window's URL themselves. The client provider also ran its OAuth callback handler in that popup, tried to exchange a code that belonged to the embedded flow, and logged "Authentication failed" on every social login even though the flow completed. - The server-side logger was hardcoded to error level, hiding warnings such as the SCIM2 profile fallback. ASGARDEO_LOG_LEVEL now selects the level. --- .changeset/console-noise-fixes.md | 2 ++ packages/nextjs/README.md | 9 +++++++++ .../contexts/Asgardeo/AsgardeoProvider.tsx | 5 +++++ packages/nextjs/src/utils/logger.ts | 17 +++++++++++++++-- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.changeset/console-noise-fixes.md b/.changeset/console-noise-fixes.md index 8505d3630..052782f97 100644 --- a/.changeset/console-noise-fixes.md +++ b/.changeset/console-noise-fixes.md @@ -5,3 +5,5 @@ - 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. - React: dialogs (for example the popup mode of `` 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". +- 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. +- 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. diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 6249fb398..704b75b88 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -32,6 +32,15 @@ A missing entry surfaces as Google's `Error 400: redirect_uri_mismatch`. `afterSignOutUrl` (default: the app origin) is sent as the post-logout redirect URI and must be registered as well. +## Logging + +The SDK logs at `error` level by default. Set `ASGARDEO_LOG_LEVEL` to `warn`, `info` or `debug` to see more, +for example why a user's profile fell back to the ID token claims: + +```bash +ASGARDEO_LOG_LEVEL=warn +``` + ## API Documentation 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). diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index f96b8cbf3..de77065f8 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -151,6 +151,11 @@ const AsgardeoClientProvider: FC> // Don't handle callback if already signed in if (isSignedIn) return; + // The embedded sign-in/sign-up flows open the identity provider in a popup named `oauth_popup` and + // read the `code`/`state` from this window's URL themselves. Handling the callback here as well would + // try to exchange a code that belongs to the embedded flow and log a spurious "Authentication failed". + if (typeof window !== 'undefined' && window.opener && window.name === 'oauth_popup') return; + (async (): Promise => { try { const code: string | null = searchParams.get('code'); diff --git a/packages/nextjs/src/utils/logger.ts b/packages/nextjs/src/utils/logger.ts index 22cc01aeb..330ca227d 100644 --- a/packages/nextjs/src/utils/logger.ts +++ b/packages/nextjs/src/utils/logger.ts @@ -16,10 +16,23 @@ * under the License. */ -import {createLogger} from '@asgardeo/node'; +import {createLogger, LogLevel} from '@asgardeo/node'; + +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; + +/** + * Resolves the server-side log level from `ASGARDEO_LOG_LEVEL`. Defaults to `error`, so that + * degraded-but-working situations the SDK reports at `warn` (for example falling back to the ID token + * when the SCIM2 profile cannot be loaded) can be surfaced by setting `ASGARDEO_LOG_LEVEL=warn`. + */ +const resolveLogLevel = (): LogLevel => { + const configured: string | undefined = process.env['ASGARDEO_LOG_LEVEL']?.toLowerCase(); + + return LOG_LEVELS.includes(configured as LogLevel) ? (configured as LogLevel) : 'error'; +}; const logger: any = createLogger({ - level: 'error', + level: resolveLogLevel(), }); export default logger;