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-redirect-signup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@asgardeo/nextjs': patch
---

Redirect-based sign-up works. `SignUpButton` (and `useAsgardeo().signUp()`) did nothing unless a custom `signUpUrl` was configured, because the server action returned an empty URL and the client threw "Not implemented" for a non-embedded sign-up. The action now resolves the configured `signUpUrl`, or the identity server's self-registration page derived from `baseUrl`, `clientId` and `applicationId` as the React SDK does, and the browser navigates there. When neither can be resolved (for example a custom domain without `signUpUrl`) the action reports an error instead of silently doing nothing.
21 changes: 19 additions & 2 deletions packages/nextjs/src/AsgardeoNextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
AsgardeoNodeClient,
AsgardeoRuntimeError,
AuthClientConfig,
Config,
CreateOrganizationPayload,
EmbeddedFlowExecuteRequestConfig,
EmbeddedFlowExecuteRequestPayload,
Expand Down Expand Up @@ -52,6 +53,7 @@ import {
getAllOrganizations,
getMeOrganizations,
getOrganization,
getRedirectBasedSignUpUrl,
getScim2Me,
getSchemas,
initializeEmbeddedSignInFlow,
Expand Down Expand Up @@ -573,13 +575,28 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
});
}
throw new AsgardeoRuntimeError(
'Not implemented',
'The Next.js client cannot navigate to the hosted sign-up page; resolve it with `getSignUpUrl()` instead.',
'AsgardeoNextClient-ValidationError-002',
'nextjs',
'The signUp method with SignUpOptions is not implemented in the Next.js client.',
'The Next.js client runs on the server. Resolve the sign-up page with `getSignUpUrl()` and navigate from the browser (`useAsgardeo().signUp()` does this).',
);
}

/**
* Gets the URL of the redirect-based sign-up page: the configured `signUpUrl`, or the identity server's
* self-registration page derived from `baseUrl`, `clientId` and `applicationId`, as in the React SDK.
*
* @returns The sign-up URL, or an empty string when none can be resolved (for example a custom domain
* without a configured `signUpUrl`).
*/
public async getSignUpUrl(): Promise<string> {
await this.ensureInitialized();

const configData: AuthClientConfig<T> = await this.asgardeo.getConfigData();

return configData?.signUpUrl || getRedirectBasedSignUpUrl(configData as unknown as Config);
}

// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
override signInSilently(_options?: SignInOptions): Promise<User | boolean> {
throw new AsgardeoRuntimeError(
Expand Down
90 changes: 90 additions & 0 deletions packages/nextjs/src/__tests__/AsgardeoNextClient.signUp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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 {beforeAll, beforeEach, describe, expect, it, vi, Mock} from 'vitest';
import AsgardeoNextClient from '../AsgardeoNextClient';

const {legacyClient} = vi.hoisted(() => {
const hoistedLegacyClient: {getConfigData: Mock; initialize: Mock} = {
getConfigData: vi.fn(),
initialize: vi.fn(),
};

return {legacyClient: hoistedLegacyClient};
});

vi.mock('@asgardeo/node', async (importOriginal: () => Promise<Record<string, unknown>>) => ({
...(await importOriginal()),
// The SDK instantiates the legacy client with `new`, which an arrow function cannot serve.
// eslint-disable-next-line prefer-arrow-callback
LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown {
return legacyClient;
}),
}));

vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn(async () => 'http://localhost:3000')}));
vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')}));

describe('AsgardeoNextClient.getSignUpUrl', () => {
const config: Record<string, unknown> = {
applicationId: 'app-id',
baseUrl: 'https://api.asgardeo.io/t/acme',
clientId: 'client-id',
clientSecret: 'client-secret',
};

let client: AsgardeoNextClient;

beforeAll(async () => {
legacyClient.getConfigData.mockResolvedValue(config);
legacyClient.initialize.mockResolvedValue(true);

client = AsgardeoNextClient.getInstance();
await client.initialize(config as any);
});

beforeEach(() => {
vi.clearAllMocks();
legacyClient.getConfigData.mockResolvedValue(config);
});

it('derives the hosted self-registration page from the Asgardeo base URL', async () => {
const signUpUrl: URL = new URL(await client.getSignUpUrl());

expect(signUpUrl.origin).toBe('https://accounts.asgardeo.io');
expect(signUpUrl.pathname).toBe('/t/acme/accountrecoveryendpoint/register.do');
expect(signUpUrl.searchParams.get('client_id')).toBe('client-id');
expect(signUpUrl.searchParams.get('spId')).toBe('app-id');
});

it('prefers the configured signUpUrl', async () => {
legacyClient.getConfigData.mockResolvedValue({...config, signUpUrl: '/signup'});

await expect(client.getSignUpUrl()).resolves.toBe('/signup');
});

it('returns an empty string when the base URL is not a recognised identity server pattern', async () => {
legacyClient.getConfigData.mockResolvedValue({...config, baseUrl: 'https://login.example.com'});

await expect(client.getSignUpUrl()).resolves.toBe('');
});

it('still rejects a programmatic signUp(options) call, pointing at getSignUpUrl', async () => {
await expect(client.signUp({})).rejects.toThrow(/getSignUpUrl/);
});
});
111 changes: 111 additions & 0 deletions packages/nextjs/src/server/actions/__tests__/signUpAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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 {beforeEach, describe, expect, it, vi, Mock} from 'vitest';
import AsgardeoNextClient from '../../../AsgardeoNextClient';
import autoSignInAfterSignUp from '../../../utils/autoSignInAfterSignUp';
import signUpAction from '../signUpAction';

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

vi.mock('../../../utils/autoSignInAfterSignUp', () => ({
default: vi.fn(),
extractSignUpCredentials: vi.fn((inputs?: Record<string, unknown>) =>
inputs?.['username'] && inputs?.['password']
? {password: inputs['password'] as string, username: inputs['username'] as string}
: undefined,
),
}));

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

const storageManager: {getConfigDataParameter: Mock} = {getConfigDataParameter: vi.fn()};
const client: {getSignUpUrl: Mock; getStorageManager: Mock; signUp: Mock} = {
getSignUpUrl: vi.fn(),
getStorageManager: vi.fn(async () => storageManager),
signUp: vi.fn(),
};

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

(AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(client);
client.getStorageManager.mockResolvedValue(storageManager);
});

it('resolves the redirect-based sign-up URL when called without a payload', async () => {
const signUpUrl: string =
'https://accounts.asgardeo.io/t/acme/accountrecoveryendpoint/register.do?client_id=client-id';

client.getSignUpUrl.mockResolvedValue(signUpUrl);

const result: ActionResult = await signUpAction();

expect(result).toEqual({data: {signUpUrl}, success: true});
expect(client.signUp).not.toHaveBeenCalled();
});

it('reports an error when no sign-up URL can be resolved', async () => {
client.getSignUpUrl.mockResolvedValue('');

const result: ActionResult = await signUpAction();

expect(result.success).toBe(false);
expect(result.error).toMatch(/signUpUrl/);
expect(result.data).toBeUndefined();
});

it('returns the next step of an incomplete embedded flow', async () => {
const nextStep: Record<string, unknown> = {flowId: 'flow-1', flowStatus: 'INCOMPLETE', type: 'VIEW'};

client.signUp.mockResolvedValue(nextStep);

const payload: {flowType: string} = {flowType: 'REGISTRATION'};
const result: ActionResult = await signUpAction(payload as any);

expect(client.signUp).toHaveBeenCalledWith(payload);
expect(client.getSignUpUrl).not.toHaveBeenCalled();
expect(result).toEqual({data: nextStep, success: true});
});

it('signs the user in and returns the after-sign-up URL when the embedded flow completes', async () => {
client.signUp.mockResolvedValue({flowId: 'flow-1', flowStatus: 'COMPLETE'});
storageManager.getConfigDataParameter.mockImplementation(async (name: string) =>
name === 'afterSignUpUrl' ? 'http://localhost:3000/welcome' : undefined,
);
(autoSignInAfterSignUp as unknown as Mock).mockResolvedValue({signedIn: true});

const result: ActionResult = await signUpAction({
flowId: 'flow-1',
inputs: {password: 'secret', username: 'jane'},
} as any);

expect(autoSignInAfterSignUp).toHaveBeenCalledWith({password: 'secret', username: 'jane'});
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
afterSignUpUrl: 'http://localhost:3000/welcome',
flowStatus: 'COMPLETE',
signedIn: true,
});
});
});
28 changes: 19 additions & 9 deletions packages/nextjs/src/server/actions/signUpAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ import autoSignInAfterSignUp, {
} from '../../utils/autoSignInAfterSignUp';

/**
* Server action for signing in a user.
* Handles the embedded sign-in flow and manages session cookies.
* Server action for signing up a user.
*
* @param payload - The embedded sign-in flow payload
* @param request - The embedded flow execute request config
* @returns Promise that resolves when sign-in is complete
* Without a payload it resolves the URL of the redirect-based sign-up page (the configured `signUpUrl`, or
* the identity server's self-registration page). With an embedded-flow payload it drives the embedded
* sign-up flow and signs the new user in when the flow completes.
*
* @param payload - The embedded sign-up flow payload
* @returns Promise that resolves with the sign-up URL, the next step of the embedded flow, or its completion
*/
const signUpAction = async (
payload?: EmbeddedFlowExecuteRequestPayload,
Expand All @@ -49,13 +51,21 @@ const signUpAction = async (
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();

// If no payload provided, redirect to sign-in URL for redirect-based sign-in.
// If there's a payload, handle the embedded sign-in flow.
// Without a payload, hand back the URL of the redirect-based sign-up page for the browser to navigate to.
if (!payload) {
const defaultSignUpUrl: string = '';
const signUpUrl: string = await client.getSignUpUrl();

if (!signUpUrl) {
return {
error:
'No sign-up URL could be resolved for the configured `baseUrl`. Configure `signUpUrl` (or `NEXT_PUBLIC_ASGARDEO_SIGN_UP_URL`) to point at your sign-up page.',
success: false,
};
}

return {data: {signUpUrl: String(defaultSignUpUrl)}, success: true};
return {data: {signUpUrl}, success: true};
}

const response: any = await client.signUp(payload);

if (response.flowStatus === EmbeddedFlowStatus.Complete) {
Expand Down
Loading