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 {
EmbeddedSelfFlowRequest,
EmbeddedSelfFlowResponse,
EmbeddedSelfFlowStatus,
} from '../../../models/embedded-self-flow';
import executeEmbeddedSelfFlow from '../executeEmbeddedSelfFlow';

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

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

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

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

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

describe('executeEmbeddedSelfFlow', (): 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(executeEmbeddedSelfFlow({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',
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 executeEmbeddedSelfFlow({
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 executeEmbeddedSelfFlow({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 executeEmbeddedSelfFlow({
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 executeEmbeddedSelfFlow({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 executeEmbeddedSelfFlow({
...endpoint,
fetcher,
payload: initiate,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfFlow-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 executeEmbeddedSelfFlow({
fetcher,
payload: undefined as unknown as EmbeddedSelfFlowRequest,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

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

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

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfFlow-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 executeEmbeddedSelfFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfFlow-ResponseError-001');
expect(error.statusCode).toBe(401);
expect(error.statusText).toBe('Unauthorized');
expect(error.message).toMatch(/^SELF 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 executeEmbeddedSelfFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

expect(error).toBeInstanceOf(ThunderIDAPIError);
expect(error.code).toBe('executeEmbeddedSelfFlow-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 executeEmbeddedSelfFlow({
fetcher,
payload: initiate,
url: URL,
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;

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

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

await expect(executeEmbeddedSelfFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(refused);
});
});
});
148 changes: 148 additions & 0 deletions packages/javascript/src/api/auth/executeEmbeddedSelfFlow.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 {EmbeddedSelfFlowRequest, EmbeddedSelfFlowResponse} from '../../models/embedded-self-flow';

/**
* Configuration for the executeEmbeddedSelfFlow request.
*/
export interface ExecuteEmbeddedSelfFlowConfig 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: EmbeddedSelfFlowRequest;
/**
* The absolute flow execution endpoint.
*/
url?: string;
}

/**
* Executes one step of a `SELF` 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
* `executeEmbeddedSelfFlow-` 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 executeEmbeddedSelfFlow({
* baseUrl: 'https://localhost:8090',
* payload: {
* flowType: 'SELF',
* handle: 'default-self-attribute-verification-flow',
* inputs: {attribute: 'email'},
* },
* fetcher: authenticatedFetcher,
* });
*
* await executeEmbeddedSelfFlow({
* baseUrl: 'https://localhost:8090',
* payload: {executionId: step.executionId, action: 'verify', inputs: {otp: '123456'}},
* fetcher: authenticatedFetcher,
* });
* ```
*/
const executeEmbeddedSelfFlow = async ({
url,
baseUrl,
payload,
fetcher,
...requestConfig
}: ExecuteEmbeddedSelfFlowConfig): Promise<EmbeddedSelfFlowResponse> => {
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)}`,
'executeEmbeddedSelfFlow-ValidationError-001',
'javascript',
400,
'The provided `url` or `baseUrl` path does not adhere to the URL schema.',
);
}

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

if (!payload.executionId && !payload.handle) {
throw new ThunderIDAPIError(
'The flow handle is required',
'executeEmbeddedSelfFlow-ValidationError-003',
'javascript',
400,
'A new SELF 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,
'executeEmbeddedSelfFlow-ResponseError-001',
'javascript',
response.status,
response.statusText,
'SELF flow request failed',
);
}

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

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

export default executeEmbeddedSelfFlow;
Loading
Loading