diff --git a/.changeset/nextjs-signin-options.md b/.changeset/nextjs-signin-options.md new file mode 100644 index 000000000..f63bc4891 --- /dev/null +++ b/.changeset/nextjs-signin-options.md @@ -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. diff --git a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx b/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx index 51c136dd9..1b78ccf72 100644 --- a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx +++ b/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx @@ -18,11 +18,11 @@ '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'; /** @@ -30,9 +30,13 @@ import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; */ 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; + signInOptions?: SignInOptions; }; /** @@ -41,8 +45,8 @@ export type SignInButtonProps = BaseSignInButtonProps & { * @example Using render props * ```tsx * - * {({isLoading}) => ( - * * )} @@ -54,10 +58,10 @@ export type SignInButtonProps = BaseSignInButtonProps & { * Sign In * ``` * - * @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 + * Sign In + * ``` */ const SignInButton: ForwardRefExoticComponent> = forwardRef< HTMLButtonElement, @@ -71,8 +75,12 @@ const SignInButton: ForwardRefExoticComponent): Promise => { + const [isLoading, setIsLoading] = useState(false); + + const handleSignIn = async (e?: MouseEvent): Promise => { try { + setIsLoading(true); + // If a custom `signInUrl` is provided, use it for navigation. if (signInUrl) { router.push(signInUrl); @@ -81,7 +89,7 @@ const SignInButton: ForwardRefExoticComponent); } } catch (error) { throw new AsgardeoRuntimeError( @@ -90,6 +98,8 @@ const SignInButton: ForwardRefExoticComponent {children ?? t('elements.buttons.signin.text')} diff --git a/packages/nextjs/src/server/actions/__tests__/signInAction.test.ts b/packages/nextjs/src/server/actions/__tests__/signInAction.test.ts new file mode 100644 index 000000000..0a5139541 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/signInAction.test.ts @@ -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>; + + 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}} = + { + 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 = {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(); + }); +}); diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 82b1a19d8..8fc422503 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -25,7 +25,7 @@ import { EmbeddedFlowExecuteRequestConfig, EmbeddedSignInFlowInitiateResponse, IdToken, - isEmpty, + SignInOptions, } from '@asgardeo/node'; import {cookies} from 'next/headers'; import AsgardeoNextClient from '../../AsgardeoNextClient'; @@ -37,14 +37,17 @@ type RequestCookies = Awaited>; /** * 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?: @@ -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 = await client.signIn(