Skip to content
Draft
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
Expand Up @@ -364,6 +364,7 @@ export interface HaapiMetadataViewDataError {
}
export interface HaapiClientOperationErrorMessages {
webauthn?: HaapiWebAuthnErrorMessages;
externalBrowserFlow?: HaapiExternalBrowserFlowErrorMessages;
}

export interface HaapiWebAuthnErrorMessages {
Expand All @@ -374,3 +375,10 @@ export interface HaapiWebAuthnErrorMessages {
/** Authentication ceremony failed for any reason other than cancel/timeout. */
authentication?: string;
}

export interface HaapiExternalBrowserFlowErrorMessages {
/** Launch failed (popup blocked, browser policy, etc.) */
launch?: string;
/** Resume failed — the parent could not consume the postMessage from the external flow (unexpected origin/type, or operation aborted). */
resume?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import { HaapiBankIdClientOperationAction } from '../../../../../data-access/typ
import { HaapiFetchFormAction } from '../../../../../data-access/types/haapi-fetch.types';
import { openBankIdApp } from './open-bankid-app';

export async function runBankIdAuthentication(action: HaapiBankIdClientOperationAction): Promise<HaapiFetchFormAction> {
export function runBankIdAuthentication(
action: HaapiBankIdClientOperationAction
): Promise<{ clientOperationData: HaapiFetchFormAction }> {
openBankIdApp(action);

const nextAction = action.model.continueActions[0];

return Promise.resolve({ action: nextAction });
return Promise.resolve({ clientOperationData: { action: nextAction } });
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ export async function performClientOperation(
}

if (isExternalBrowserFlowClientOperation(action)) {
return runExternalBrowserFlow(action, 2500, signal).then(clientOperationData => ({ clientOperationData }));
return runExternalBrowserFlow(action, 2500, signal, currentStep);
}

if (isBankIdClientOperation(action)) {
return runBankIdAuthentication(action).then(clientOperationData => ({ clientOperationData }));
return runBankIdAuthentication(action);
}

throw new Error(`Unsupported client operation: ${action.model.name}`);
Expand All @@ -71,9 +71,9 @@ export async function performClientOperation(
* Synthesises a {@link HaapiStepperError} for a client-operation failure (IS-11327).
*
* Client-operation failures (WebAuthn ceremony cancel / timeout / parse error / unsupported
* API today; BankID / EBF on the same pattern when their per-operation error handling lands)
* happen on the client and aren't part of the HAAPI response, so the stepper has no native
* category for them. We treat them as `AppError`-class problems of the current step — building
* API; EBF popup-blocked / unexpected-resume) happen on the client and aren't part of the
* HAAPI response, so the stepper has no native category for them. We treat them as
* `AppError`-class problems of the current step — building
* a `HaapiUnexpectedProblemStep` via {@link formatErrorStepData} — so they surface via
* `useHaapiStepper().error.app` like any server-driven problem and consumers handle them
* through the same channel (e.g. `HaapiStepperErrorNotifier`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2025 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { runExternalBrowserFlow } from './external-browser-flow';
import { HAAPI_PROBLEM_STEPS, HAAPI_STEPS } from '../../../../../data-access/types/haapi-step.types';
import { createMockExternalBrowserFlowAction, createMockStep } from '../../../../../util/tests/mocks';

describe('external-browser-flow', () => {
const launchOrigin = new URL(createMockExternalBrowserFlowAction().model.arguments.href).origin;
const closeDelay = 0;
const stepWithoutErrorMetadata = createMockStep(HAAPI_STEPS.AUTHENTICATION, { metadata: {} });
const failedStep = createMockStep(HAAPI_STEPS.AUTHENTICATION, {
metadata: {
viewData: {
error: {
clientOperation: {
externalBrowserFlow: { launch: 'Launch error copy.', resume: 'Resume error copy.' },
},
},
},
},
});

let abortController: AbortController;
let externalWindowClose: ReturnType<typeof vi.fn>;
let fakeExternalWindow: Window;
let openSpy: MockInstance<typeof window.open>;

beforeEach(() => {
abortController = new AbortController();
externalWindowClose = vi.fn();
fakeExternalWindow = { close: externalWindowClose } as unknown as Window;
openSpy = vi.spyOn(window, 'open').mockReturnValue(fakeExternalWindow);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('runExternalBrowserFlow', () => {
describe('success', () => {
it('opens the launch URL with for_origin appended', () => {
const action = createMockExternalBrowserFlowAction();
void runExternalBrowserFlow(action, closeDelay, abortController.signal, stepWithoutErrorMetadata);

const expected = new URL(action.model.arguments.href);
expected.searchParams.set('for_origin', window.location.origin);

const opened = openSpy.mock.calls[0][0] as URL;
expect(opened.href).toBe(expected.href);
});

it('awaits postMessage from the external window and resolves with continuation data and nonce payload', async () => {
const action = createMockExternalBrowserFlowAction();
const externalBrowserFlowResult = runExternalBrowserFlow(
action,
closeDelay,
abortController.signal,
stepWithoutErrorMetadata
);

sendBrowserMessage({ source: fakeExternalWindow, origin: launchOrigin, data: 'nonce-abc' });

await expect(externalBrowserFlowResult).resolves.toEqual({
clientOperationData: {
action: action.model.continueActions[0],
payload: new Map([['_resume_nonce', 'nonce-abc']]),
},
});
});

it('ignores messages whose source is not the external window', async () => {
const action = createMockExternalBrowserFlowAction();
const externalBrowserFlowResult = runExternalBrowserFlow(
action,
closeDelay,
abortController.signal,
stepWithoutErrorMetadata
);

sendBrowserMessage({ source: window, origin: launchOrigin, data: 'wrong-source' });
sendBrowserMessage({ source: fakeExternalWindow, origin: launchOrigin, data: 'nonce-ok' });

await expect(externalBrowserFlowResult).resolves.toEqual({
clientOperationData: {
action: action.model.continueActions[0],
payload: new Map([['_resume_nonce', 'nonce-ok']]),
},
});
});
});

describe('failure', () => {
it('window.open returns null → launch error copy', async () => {
openSpy.mockReturnValue(null);

const result = await runExternalBrowserFlow(
createMockExternalBrowserFlowAction(),
closeDelay,
abortController.signal,
failedStep
);

expect(result).toMatchObject({
clientOperationError: {
app: {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
messages: [{ text: 'Launch error copy.' }],
},
},
});
expect(externalWindowClose).not.toHaveBeenCalled();
});

it('message from unexpected origin → resume error copy', async () => {
const externalBrowserFlowResult = runExternalBrowserFlow(
createMockExternalBrowserFlowAction(),
closeDelay,
abortController.signal,
failedStep
);

sendBrowserMessage({ source: fakeExternalWindow, origin: 'http://attacker.example', data: 'nonce-x' });

await expect(externalBrowserFlowResult).resolves.toMatchObject({
clientOperationError: {
app: {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
messages: [{ text: 'Resume error copy.' }],
},
},
});
expect(externalWindowClose).toHaveBeenCalledTimes(1);
});

it('message with non-string data → resume error copy', async () => {
const externalBrowserFlowResult = runExternalBrowserFlow(
createMockExternalBrowserFlowAction(),
closeDelay,
abortController.signal,
failedStep
);

sendBrowserMessage({ source: fakeExternalWindow, origin: launchOrigin, data: { not: 'a string' } });

await expect(externalBrowserFlowResult).resolves.toMatchObject({
clientOperationError: {
app: {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
messages: [{ text: 'Resume error copy.' }],
},
},
});
expect(externalWindowClose).toHaveBeenCalledTimes(1);
});

it('abort signal fires → resume error copy', async () => {
const externalBrowserFlowResult = runExternalBrowserFlow(
createMockExternalBrowserFlowAction(),
closeDelay,
abortController.signal,
failedStep
);

abortController.abort();

await expect(externalBrowserFlowResult).resolves.toMatchObject({
clientOperationError: {
app: {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
messages: [{ text: 'Resume error copy.' }],
},
},
});
expect(externalWindowClose).toHaveBeenCalledTimes(1);
});
});

describe('metadata-key fallback', () => {
it('step has no externalBrowserFlow error copy → synthesised error has no messages', async () => {
openSpy.mockReturnValue(null);

const result = await runExternalBrowserFlow(
createMockExternalBrowserFlowAction(),
closeDelay,
abortController.signal,
stepWithoutErrorMetadata
);

expect(result).toMatchObject({
clientOperationError: {
app: {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
messages: [],
},
},
});
});
});
});
});

function sendBrowserMessage({
source,
origin,
data,
}: {
source: MessageEventSource | null;
origin: string;
data: unknown;
}) {
window.dispatchEvent(new MessageEvent('message', { source, origin, data }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,33 @@ import {
HAAPI_ACTION_CLIENT_OPERATIONS,
HAAPI_ACTION_TYPES,
HaapiExternalBrowserFlowClientOperationAction,
} from '../../../../data-access/types/haapi-action.types';
import { HaapiFetchFormAction } from '../../../../data-access/types/haapi-fetch.types';
} from '../../../../../data-access/types/haapi-action.types';
import { HaapiStep } from '../../../../../data-access/types/haapi-step.types';
import { ClientOperationResult } from '../typings';
import { getHaapiStepperError } from '../client-operations';
import { EXTERNAL_BROWSER_FLOW_ERROR_TYPE } from './typings';

/**
* Executes an external browser flow by opening a new window in the launch URL defined by the action and waiting for
* the completion message from that window.
*
* When the flow completes, the returned promise resolves with the form action and values that should be used to resume
* the flow via HAAPI.
*
* The flow can be cancelled by aborting the provided AbortSignal, in which case the external window is closed and the
* returned promise is rejected.
*
* @param action the external browser flow action to execute
* @param closeDelay the delay in milliseconds before closing the external window after successful completion
* @param abortSignal an AbortSignal to listen to for cancellation of the flow
* @returns a promise that represents the execution of the external browser flow
* Executes an external browser flow by opening a new window at the launch URL and waiting for the
* completion message from that window.
*/
export function runExternalBrowserFlow(
action: HaapiExternalBrowserFlowClientOperationAction,
closeDelay: number,
abortSignal: AbortSignal
): Promise<HaapiFetchFormAction> {
return new Promise((resolve, reject) => {
abortSignal: AbortSignal,
currentStep: HaapiStep | null
): Promise<ClientOperationResult> {
return new Promise(resolve => {
const launchUrl = new URL(action.model.arguments.href);
launchUrl.searchParams.set('for_origin', window.location.origin);

const externalWindow = window.open(launchUrl);
if (!externalWindow) {
reject(new Error('Failed to open external browser window'));
resolve({
clientOperationError: getHaapiStepperError(
getExternalBrowserFlowErrorMessage(EXTERNAL_BROWSER_FLOW_ERROR_TYPE.LAUNCH, currentStep)
),
});
return;
}

Expand All @@ -52,17 +49,31 @@ export function runExternalBrowserFlow(
return;
}
if (event.origin !== launchUrl.origin || typeof event.data !== 'string') {
reject(new Error('External browser flow: unexpected origin or type in resume message'));
cleanup(true);
resolve({
clientOperationError: getHaapiStepperError(
getExternalBrowserFlowErrorMessage(EXTERNAL_BROWSER_FLOW_ERROR_TYPE.RESUME, currentStep)
),
});
return;
}

cleanup(false);
resolve({ action: action.model.continueActions[0], payload: new Map([['_resume_nonce', event.data]]) });
resolve({
clientOperationData: {
action: action.model.continueActions[0],
payload: new Map([['_resume_nonce', event.data]]),
},
});
};

const onAbort = () => {
cleanup(true);
reject(abortSignal.reason as Error);
resolve({
clientOperationError: getHaapiStepperError(
getExternalBrowserFlowErrorMessage(EXTERNAL_BROWSER_FLOW_ERROR_TYPE.RESUME, currentStep)
),
});
};

window.addEventListener('message', onMessage);
Expand All @@ -80,6 +91,16 @@ export function runExternalBrowserFlow(
});
}

function getExternalBrowserFlowErrorMessage(
type: EXTERNAL_BROWSER_FLOW_ERROR_TYPE,
currentStep: HaapiStep | null
): string | undefined {
const externalBrowserFlowErrors = currentStep?.metadata?.viewData?.error?.clientOperation?.externalBrowserFlow;
return type === EXTERNAL_BROWSER_FLOW_ERROR_TYPE.LAUNCH
? externalBrowserFlowErrors?.launch
: externalBrowserFlowErrors?.resume;
}

export const isExternalBrowserFlowClientOperation = (
action: HaapiAction
): action is HaapiExternalBrowserFlowClientOperationAction =>
Expand Down
Loading
Loading