Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/nextjs-signin-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@asgardeo/nextjs': patch
---

`signInOptions` now reach the authorize request. The `signInOptions` configured on `AsgardeoProvider` (for example `fidp` or `prompt`) were never applied to the redirect-based sign-in, and passing `signInOptions` to `SignInButton` made the click fail because the server action mistook any non-empty object for an embedded-flow step. The action now only treats a payload with a `flowId` as an embedded-flow step and appends the configured options plus the caller's options to the authorize request. `SignInButton` also tracks its loading state and hands `signIn` and `isLoading` to render-prop children, as the React SDK does.
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,25 @@

'use client';

import {AsgardeoRuntimeError} from '@asgardeo/node';
import {AsgardeoRuntimeError, SignInOptions} from '@asgardeo/node';
import {BaseSignInButton, BaseSignInButtonProps, useTranslation} from '@asgardeo/react';
import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime';
import {useRouter} from 'next/navigation';
import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes, MouseEvent} from 'react';
import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes, MouseEvent, useState} from 'react';
import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo';

/**
* Props interface of {@link SignInButton}
*/
export type SignInButtonProps = BaseSignInButtonProps & {
/**
* Additional parameters to pass to the `authorize` request.
* Additional parameters to pass to the `authorize` request, on top of the `signInOptions` configured
* on the provider.
*
* @example
* signInOptions: { prompt: "login", fidp: "OrganizationSSO" }
*/
signInOptions?: Record<string, any>;
signInOptions?: SignInOptions;
};

/**
Expand All @@ -41,8 +45,8 @@ export type SignInButtonProps = BaseSignInButtonProps & {
* @example Using render props
* ```tsx
* <SignInButton>
* {({isLoading}) => (
* <button type="submit" disabled={isLoading}>
* {({signIn, isLoading}) => (
* <button onClick={signIn} disabled={isLoading}>
* {isLoading ? 'Signing in...' : 'Sign In'}
* </button>
* )}
Expand All @@ -54,10 +58,10 @@ export type SignInButtonProps = BaseSignInButtonProps & {
* <SignInButton className="custom-button">Sign In</SignInButton>
* ```
*
* @remarks
* In Next.js with server actions, the sign-in is handled via the server action.
* When using render props, the custom button should use `type="submit"` instead of `onClick={signIn}`.
* The `signIn` function in render props is provided for API consistency but should not be used directly.
* @example Passing additional authorize request parameters
* ```tsx
* <SignInButton signInOptions={{prompt: 'login'}}>Sign In</SignInButton>
* ```
*/
const SignInButton: ForwardRefExoticComponent<SignInButtonProps & RefAttributes<HTMLButtonElement>> = forwardRef<
HTMLButtonElement,
Expand All @@ -71,8 +75,12 @@ const SignInButton: ForwardRefExoticComponent<SignInButtonProps & RefAttributes<
const router: AppRouterInstance = useRouter();
const {t} = useTranslation(preferences?.i18n);

const handleOnClick = async (e: MouseEvent<HTMLButtonElement>): Promise<void> => {
const [isLoading, setIsLoading] = useState<boolean>(false);

const handleSignIn = async (e?: MouseEvent<HTMLButtonElement>): Promise<void> => {
try {
setIsLoading(true);

// If a custom `signInUrl` is provided, use it for navigation.
if (signInUrl) {
router.push(signInUrl);
Expand All @@ -81,7 +89,7 @@ const SignInButton: ForwardRefExoticComponent<SignInButtonProps & RefAttributes<
}

if (onClick) {
onClick(e);
onClick(e as MouseEvent<HTMLButtonElement>);
}
} catch (error) {
throw new AsgardeoRuntimeError(
Expand All @@ -90,6 +98,8 @@ const SignInButton: ForwardRefExoticComponent<SignInButtonProps & RefAttributes<
'nextjs',
'Something went wrong while trying to sign in. Please try again later.',
);
} finally {
setIsLoading(false);
}
};

Expand All @@ -99,7 +109,9 @@ const SignInButton: ForwardRefExoticComponent<SignInButtonProps & RefAttributes<
style={style}
ref={ref}
preferences={preferences}
onClick={handleOnClick}
onClick={handleSignIn}
isLoading={isLoading}
signIn={handleSignIn}
{...rest}
>
{children ?? t('elements.buttons.signin.text')}
Expand Down
137 changes: 137 additions & 0 deletions packages/nextjs/src/server/actions/__tests__/signInAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* 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 {cookies} from 'next/headers';
import {beforeEach, describe, expect, it, vi, Mock} from 'vitest';
import AsgardeoNextClient from '../../../AsgardeoNextClient';
import SessionManager from '../../../utils/SessionManager';
import signInAction from '../signInAction';

vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));

vi.mock('../../../AsgardeoNextClient', () => ({
default: {
getInstance: vi.fn(),
},
}));

vi.mock('../../../utils/SessionManager', () => ({
default: {
createTempSession: vi.fn(),
getSessionCookieName: vi.fn(() => 'session'),
getTempSessionCookieName: vi.fn(() => 'temp-session'),
getTempSessionCookieOptions: vi.fn(() => ({httpOnly: true})),
verifySessionToken: vi.fn(),
verifyTempSession: vi.fn(),
},
}));

vi.mock('../../../utils/logger', () => ({
default: {debug: vi.fn(), error: vi.fn(), warn: vi.fn()},
}));

describe('signInAction', () => {
type ActionResult = Awaited<ReturnType<typeof signInAction>>;

const client: {getAuthorizeRequestUrl: Mock; getConfiguration: Mock; signIn: Mock} = {
getAuthorizeRequestUrl: vi.fn(),
getConfiguration: vi.fn(),
signIn: vi.fn(),
};
const cookieStore: {delete: Mock; get: Mock; set: Mock} = {delete: vi.fn(), get: vi.fn(), set: vi.fn()};
const authorizeUrl: string = 'https://api.asgardeo.io/t/acme/oauth2/authorize?client_id=client-id';

beforeEach(() => {
vi.clearAllMocks();

(AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(client);
(cookies as unknown as Mock).mockResolvedValue(cookieStore);
(SessionManager.verifyTempSession as unknown as Mock).mockResolvedValue({sessionId: 'session-1'});
(SessionManager.createTempSession as unknown as Mock).mockResolvedValue('temp.jwt');

// No session cookies: a temporary session is created for the sign-in.
cookieStore.get.mockReturnValue(undefined);

client.getConfiguration.mockResolvedValue({signInOptions: {fidp: 'OrganizationSSO'}});
client.getAuthorizeRequestUrl.mockResolvedValue(authorizeUrl);
});

it('resolves the redirect-based sign-in URL with the configured signInOptions when called without a payload', async () => {
const result: ActionResult = await signInAction();

expect(client.getAuthorizeRequestUrl).toHaveBeenCalledWith({fidp: 'OrganizationSSO'}, expect.any(String));
expect(client.signIn).not.toHaveBeenCalled();
expect(result).toEqual({data: {signInUrl: authorizeUrl}, success: true});
expect(cookieStore.set).toHaveBeenCalledWith('temp-session', 'temp.jwt', {httpOnly: true});
});

it('treats an empty payload like no payload', async () => {
await signInAction({});

expect(client.getAuthorizeRequestUrl).toHaveBeenCalledWith({fidp: 'OrganizationSSO'}, expect.any(String));
expect(client.signIn).not.toHaveBeenCalled();
});

it("appends the caller's sign-in options to the authorize request on top of the configured ones", async () => {
const result: ActionResult = await signInAction({fidp: 'GoogleIdP', prompt: 'login'});

expect(client.getAuthorizeRequestUrl).toHaveBeenCalledWith(
{fidp: 'GoogleIdP', prompt: 'login'},
expect.any(String),
);
expect(client.signIn).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});

it('works without configured signInOptions', async () => {
client.getConfiguration.mockResolvedValue({});

await signInAction({prompt: 'login'});

expect(client.getAuthorizeRequestUrl).toHaveBeenCalledWith({prompt: 'login'}, expect.any(String));
});

it('drives the embedded flow when the payload is an embedded-flow step', async () => {
const payload: {flowId: string; selectedAuthenticator: {authenticatorId: string; params: Record<string, string>}} =
{
flowId: 'flow-1',
selectedAuthenticator: {authenticatorId: 'BasicAuthenticator', params: {password: 'secret', username: 'jane'}},
};
const request: {method: string; url: string} = {method: 'POST', url: 'https://api.asgardeo.io/t/acme/oauth2/authn'};
const nextStep: Record<string, unknown> = {flowId: 'flow-1', flowStatus: 'INCOMPLETE', nextStep: {}};

client.signIn.mockResolvedValue(nextStep);

const result: ActionResult = await signInAction(payload, request);

expect(client.signIn).toHaveBeenCalledWith(payload, request, expect.any(String));
expect(client.getAuthorizeRequestUrl).not.toHaveBeenCalled();
expect(result).toEqual({data: nextStep, success: true});
});

it('reuses the session ID of an existing temporary session', async () => {
cookieStore.get.mockImplementation((name: string) => (name === 'temp-session' ? {value: 'temp.jwt'} : undefined));

await signInAction();

expect(client.getAuthorizeRequestUrl).toHaveBeenCalledWith({fidp: 'OrganizationSSO'}, 'session-1');
expect(SessionManager.createTempSession).not.toHaveBeenCalled();
});
});
23 changes: 15 additions & 8 deletions packages/nextjs/src/server/actions/signInAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
EmbeddedFlowExecuteRequestConfig,
EmbeddedSignInFlowInitiateResponse,
IdToken,
isEmpty,
SignInOptions,
} from '@asgardeo/node';
import {cookies} from 'next/headers';
import AsgardeoNextClient from '../../AsgardeoNextClient';
Expand All @@ -37,14 +37,17 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;

/**
* Server action for signing in a user.
* Handles the embedded sign-in flow and manages session cookies.
*
* @param payload - The embedded sign-in flow payload
* Without an embedded-flow step it resolves the URL of the redirect-based sign-in, with the configured
* `signInOptions` and any additional `options` appended to the authorize request. With an embedded-flow
* step (identified by its `flowId`) it drives the embedded sign-in flow and manages the session cookies.
*
* @param payload - Additional authorize request parameters, or the embedded sign-in flow payload
* @param request - The embedded flow execute request config
* @returns Promise that resolves when sign-in is complete
*/
const signInAction = async (
payload?: EmbeddedSignInFlowHandleRequestPayload,
payload?: EmbeddedSignInFlowHandleRequestPayload | SignInOptions,
request?: EmbeddedFlowExecuteRequestConfig,
): Promise<{
data?:
Expand Down Expand Up @@ -98,14 +101,18 @@ const signInAction = async (
);
}

// If no payload provided, redirect to sign-in URL for redirect-based sign-in.
if (!payload || isEmpty(payload)) {
const defaultSignInUrl: string = await client.getAuthorizeRequestUrl({}, sessionId);
// Anything but an embedded-flow step starts the redirect-based sign-in. The configured `signInOptions`
// (e.g. `fidp`) are appended to the authorize request, with the options passed by the caller on top.
if (!payload || !('flowId' in payload)) {
const config: AsgardeoNextConfig = await client.getConfiguration();
const authorizeRequestParams: SignInOptions = {...(config?.signInOptions ?? {}), ...(payload ?? {})};
const defaultSignInUrl: string = await client.getAuthorizeRequestUrl(authorizeRequestParams, sessionId);

return {data: {signInUrl: String(defaultSignInUrl)}, success: true};
}

// Handle embedded sign-in flow
const response: any = await client.signIn(payload, request!, sessionId);
const response: any = await client.signIn(payload as EmbeddedSignInFlowHandleRequestPayload, request!, sessionId);

if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) {
const signInResult: Record<string, unknown> = await client.signIn(
Expand Down
Loading