Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/console-noise-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@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 `<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".
- 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.
9 changes: 9 additions & 0 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 12 additions & 6 deletions packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -150,6 +151,11 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
// 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<void> => {
try {
const code: string | null = searchParams.get('code');
Expand Down Expand Up @@ -177,7 +183,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
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();
Expand Down Expand Up @@ -215,14 +221,14 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>

// 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;
}
Expand Down Expand Up @@ -251,7 +257,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>

// 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;
}
Expand All @@ -261,7 +267,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
const {afterSignUpUrl, autoSignInSkippedReason, signedIn, ...flowResponse}: any = result.data;

// A URL passed by the caller (e.g. the `afterSignUpUrl` prop of `<SignUp />`) 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.
Expand Down Expand Up @@ -295,7 +301,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
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};
}
Expand Down
66 changes: 66 additions & 0 deletions packages/nextjs/src/utils/__tests__/navigateTo.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 15 additions & 2 deletions packages/nextjs/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
61 changes: 61 additions & 0 deletions packages/nextjs/src/utils/navigateTo.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 9 additions & 1 deletion packages/react/src/components/primitives/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,17 @@ export const DialogContent: ForwardRefExoticComponent<HTMLProps<HTMLDivElement>
className={cx(withVendorCSSClassPrefix(bem('dialog', 'overlay')), styles['overlay'])}
lockScroll
>
<FloatingFocusManager context={floatingContext} initialFocus={-1}>
{/*
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.
*/}
<FloatingFocusManager context={floatingContext} initialFocus={context.refs.floating}>
<div
ref={ref}
tabIndex={-1}
className={cx(withVendorCSSClassPrefix(bem('dialog', 'content')), styles['content'], props.className)}
aria-labelledby={context.labelId}
aria-describedby={context.descriptionId}
Expand Down
Loading