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
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {beforeEach, describe, expect, it, Mock, vi} from 'vitest';
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
import {EmbeddedFlowType} from '../../../models/embedded-flow';
import {
EmbeddedSelfServiceFlowRequest,
EmbeddedSelfServiceFlowResponse,
EmbeddedSelfServiceFlowStatus,
} from '../../../models/embedded-self-service-flow';
import executeEmbeddedSelfServiceFlow from '../executeEmbeddedSelfServiceFlow';

const URL = 'https://localhost:8090/flow/execute';

const initiate: EmbeddedSelfServiceFlowRequest = {
flowType: EmbeddedFlowType.SelfService,
handle: 'default-self-attribute-verification-flow',
inputs: {attribute: 'email'},
};

type Fetcher = (url: string, config: RequestInit) => Promise<Response>;

const flowResponse: EmbeddedSelfServiceFlowResponse = {
data: {additionalData: {emailSent: 'true', maskedDestination: 'a•••@example.com'}},
executionId: 'exec-1',
flowStatus: EmbeddedSelfServiceFlowStatus.Incomplete,
};

const lastRequest = (fetcher: Mock<Fetcher>): [string, RequestInit] =>
fetcher.mock.calls[fetcher.mock.calls.length - 1] as [string, RequestInit];

describe('executeEmbeddedSelfServiceFlow', (): void => {
let fetcher: Mock<Fetcher>;

beforeEach((): void => {
fetcher = vi.fn<Fetcher>().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true} as Response);
});

it('starts the flow by type and handle with its inputs and returns the step', async (): Promise<void> => {
await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(flowResponse);

const [url, init] = lastRequest(fetcher);
expect(url).toBe(URL);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({
flowType: 'SELF_SERVICE',
handle: 'default-self-attribute-verification-flow',
inputs: {attribute: 'email'},
verbose: true,
});
expect(init.headers).toMatchObject({Accept: 'application/json', 'Content-Type': 'application/json'});
});

it('continues an execution without naming the handle again', async (): Promise<void> => {
await executeEmbeddedSelfServiceFlow({
fetcher,
payload: {action: 'verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}},
url: URL,
});

expect(JSON.parse(lastRequest(fetcher)[1].body as string)).toEqual({
action: 'verify',
challengeToken: 'ct-1',
executionId: 'exec-1',
inputs: {otp: '123456'},
verbose: true,
});
});

it('builds the endpoint from baseUrl when url is not given', async (): Promise<void> => {
await executeEmbeddedSelfServiceFlow({baseUrl: 'https://localhost:8090', fetcher, payload: initiate});

expect(lastRequest(fetcher)[0]).toBe('https://localhost:8090/flow/execute');
});

it('keeps caller headers while setting the JSON headers', async (): Promise<void> => {
await executeEmbeddedSelfServiceFlow({
fetcher,
headers: {'X-Request-Id': 'req-1'},
payload: initiate,
url: URL,
});

expect(lastRequest(fetcher)[1].headers).toMatchObject({
'Content-Type': 'application/json',
'X-Request-Id': 'req-1',
});
});

it('uses native fetch when no fetcher is given', async (): Promise<void> => {
const nativeFetch = vi.fn().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true});
vi.stubGlobal('fetch', nativeFetch);

try {
await executeEmbeddedSelfServiceFlow({payload: initiate, url: URL});
} finally {
vi.unstubAllGlobals();
}

expect(nativeFetch).toHaveBeenCalledWith(URL, expect.objectContaining({method: 'POST'}));
});

describe('validation', (): void => {
it.each([
['no url', {}],
['an invalid url', {url: 'not-a-url'}],
['an invalid baseUrl', {baseUrl: 'invalid'}],
])('refuses %s before sending', async (_name: string, endpoint: Record<string, string>): Promise<void> => {
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
...endpoint,
fetcher,
payload: initiate,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-001');
expect(error.statusCode).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});

it('refuses a missing payload before sending', async (): Promise<void> => {
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
fetcher,
payload: undefined as unknown as EmbeddedSelfServiceFlowRequest,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-002');
expect(fetcher).not.toHaveBeenCalled();
});

it.each<[string, EmbeddedSelfServiceFlowRequest]>([
['no handle', {flowType: EmbeddedFlowType.SelfService, inputs: {attribute: 'email'}}],
['an empty handle', {flowType: EmbeddedFlowType.SelfService, handle: '', inputs: {attribute: 'email'}}],
])(
'refuses a new flow with %s before sending',
async (_name: string, payload: EmbeddedSelfServiceFlowRequest): Promise<void> => {
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
fetcher,
payload,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-003');
expect(fetcher).not.toHaveBeenCalled();
},
);
});

describe('response errors', (): void => {
it('maps a failed response to an API error with its status', async (): Promise<void> => {
fetcher.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () => Promise.resolve('{"code":"AUTH-1001","message":"Unauthorized","description":"invalid token"}'),
} as unknown as Response);

const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ResponseError-001');
expect(error.statusCode).toBe(401);
expect(error.statusText).toBe('Unauthorized');
expect(error.message).toMatch(/^Self-service flow request failed: /);
});

it('maps a network failure to a network error', async (): Promise<void> => {
fetcher.mockRejectedValue(new TypeError('Failed to fetch'));

const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001');
expect(error.statusCode).toBe(0);
expect(error.message).toContain('Failed to fetch');
});

it('maps an unreadable body to a network error', async (): Promise<void> => {
fetcher.mockResolvedValue({
json: () => Promise.reject(new SyntaxError('Unexpected token')),
ok: true,
} as unknown as Response);

const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001');
});

it('returns a flow error step as data rather than throwing', async (): Promise<void> => {
const refused: EmbeddedSelfServiceFlowResponse = {
error: {code: 'FET-1112'} as EmbeddedSelfServiceFlowResponse['error'],
executionId: 'exec-1',
flowStatus: EmbeddedSelfServiceFlowStatus.Error,
};
fetcher.mockResolvedValue({json: () => Promise.resolve(refused), ok: true} as unknown as Response);

await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(refused);
});
});
});
148 changes: 148 additions & 0 deletions packages/javascript/src/api/auth/executeEmbeddedSelfServiceFlow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedSelfServiceFlowRequest, EmbeddedSelfServiceFlowResponse} from '../../models/embedded-self-service-flow';

/**
* Configuration for the executeEmbeddedSelfServiceFlow request.
*/
export interface ExecuteEmbeddedSelfServiceFlowConfig extends Omit<RequestInit, 'method' | 'body'> {
/**
* The base path of the API endpoint.
*/
baseUrl?: string;
/**
* Optional custom fetcher function. If not provided, native fetch is used, which sends no
* access token; framework SDKs pass a fetcher that attaches the signed-in user's token.
*/
fetcher?: (url: string, config: RequestInit) => Promise<Response>;
/**
* The step to execute: an initiating request or a continuation.
*/
payload: EmbeddedSelfServiceFlowRequest;
/**
* The absolute flow execution endpoint.
*/
url?: string;
}

/**
* Executes one step of a self-service flow, an operation the signed-in user performs on their own
* account, such as verifying their email address. A new execution names the flow by its handle; the
* server takes the user from the access token on that first request and keeps it for the execution.
*
* @param config - Configuration object with URL, payload, fetcher and optional request config.
* @returns A promise that resolves with the flow step.
* @throws ThunderIDAPIError when the input is invalid or the request fails, coded
* `executeEmbeddedSelfServiceFlow-` plus `ValidationError-001` (invalid `url` or `baseUrl`), `ValidationError-002`
* (no payload), `ValidationError-003` (a new flow without a handle), `ResponseError-001` (the server refused, with
* its status) or `NetworkError-001` (no response, or an unreadable one).
*
* @example
* ```typescript
* const step = await executeEmbeddedSelfServiceFlow({
* baseUrl: 'https://localhost:8090',
* payload: {
* flowType: 'SELF_SERVICE',
* handle: 'default-self-attribute-verification-flow',
* inputs: {attribute: 'email'},
* },
* fetcher: authenticatedFetcher,
* });
*
* await executeEmbeddedSelfServiceFlow({
* baseUrl: 'https://localhost:8090',
* payload: {executionId: step.executionId, action: 'verify', inputs: {otp: '123456'}},
* fetcher: authenticatedFetcher,
* });
* ```
*/
const executeEmbeddedSelfServiceFlow = async ({
url,
baseUrl,
payload,
fetcher,
...requestConfig
}: ExecuteEmbeddedSelfServiceFlowConfig): Promise<EmbeddedSelfServiceFlowResponse> => {
try {
// eslint-disable-next-line no-new
new URL((url ?? baseUrl)!);
} catch (error) {
throw new ThunderIDAPIError(
`Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`,
'executeEmbeddedSelfServiceFlow-ValidationError-001',
'javascript',
400,
'The provided `url` or `baseUrl` path does not adhere to the URL schema.',
);
}

if (!payload) {
throw new ThunderIDAPIError(
'Self-service flow payload is required',
'executeEmbeddedSelfServiceFlow-ValidationError-002',
'javascript',
400,
'If a self-service flow payload is not provided, the request cannot be constructed correctly.',
);
}

if (!payload.executionId && !payload.handle) {
throw new ThunderIDAPIError(
'The flow handle is required',
'executeEmbeddedSelfServiceFlow-ValidationError-003',
'javascript',
400,
'A new self-service flow must name the flow to start by its handle.',
);
}

const fetchFn: typeof fetch = fetcher ?? fetch;
const resolvedUrl: string = url ?? `${baseUrl}/flow/execute`;

const requestInit: RequestInit = {
...requestConfig,
// Every step is rendered from `meta`, so always ask for it.
body: JSON.stringify({...payload, verbose: true}),
headers: {
...requestConfig.headers,
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
};

try {
const response: Response = await fetchFn(resolvedUrl, requestInit);

if (!response?.ok) {
const errorText: string = await response.text();

throw new ThunderIDAPIError(
errorText,
'executeEmbeddedSelfServiceFlow-ResponseError-001',
'javascript',
response.status,
response.statusText,
'Self-service flow request failed',
);
}

return (await response.json()) as EmbeddedSelfServiceFlowResponse;
} catch (error) {
if (error instanceof ThunderIDAPIError) {
throw error;
}

throw new ThunderIDAPIError(
`Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`,
'executeEmbeddedSelfServiceFlow-NetworkError-001',
'javascript',
0,
'Network Error',
);
}
};

export default executeEmbeddedSelfServiceFlow;
Loading
Loading