Skip to content
Merged
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 @@ -20,6 +20,8 @@ import { HTTP_METHODS } from '../../data-access/types/haapi-form.types';
import { MEDIA_TYPES } from '../../../shared/util/types/media.types';
import {
authenticationStep,
completedWithErrorStep,
completedWithErrorStepWithoutLinks,
completedWithSuccessStep,
completedWithSuccessStepWithoutLinks,
continueSameStep,
Expand All @@ -31,7 +33,11 @@ import {
} from '../../../shared/util/api-responses';
import { act } from 'react';
import { useHaapiStepper } from './HaapiStepperHook';
import type { HaapiStepperHistoryEntry, HaapiStepperNextStepAction } from './haapi-stepper.types';
import type {
HaapiStepperCompletedStep,
HaapiStepperHistoryEntry,
HaapiStepperNextStepAction,
} from './haapi-stepper.types';
import { HaapiStepperActionStep, HaapiStepperFormAction } from './haapi-stepper.types';
import { isQrCodeLink } from '../../util/isQrCodeLink';
import type { BootstrapConfiguration } from '../../data-access/bootstrap-configuration';
Expand Down Expand Up @@ -805,12 +811,21 @@ describe('HaapiStepper', () => {
});
});

describe('Completed With Success Step', () => {
const authorizationResponseUrl = completedWithSuccessStep.links?.find(
link => link.rel === 'authorization-response'
)?.href;

describe('redirectOnAuthenticationCompletedWithSuccess enabled (default)', () => {
describe.each([
{
label: 'success',
stepType: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
stepFixture: completedWithSuccessStep,
},
{
label: 'error',
stepType: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
stepFixture: completedWithErrorStep,
},
] as const)('Completed With $label Step', ({ label, stepType, stepFixture }) => {
Comment thread
aleixsuau marked this conversation as resolved.
const authorizationResponseUrl = stepFixture.links?.find(link => link.rel === 'authorization-response')?.href;

describe('autoRedirectOnAuthenticationComplete enabled (default)', () => {
it('should redirect to the authorization-response URL', async () => {
render(
<HaapiStepper>
Expand All @@ -819,7 +834,7 @@ describe('HaapiStepper', () => {
);

await screen.findByTestId('step-type');
await goToNextStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS);
await goToNextStep(stepType);

await waitFor(() => {
expect(window.location.href).toBe(authorizationResponseUrl);
Expand All @@ -834,11 +849,28 @@ describe('HaapiStepper', () => {
);

await screen.findByTestId('step-type');
await goToNextStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS, { noLinks: true });
await goToNextStep(stepType, { noLinks: true });

await waitFor(() => {
expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
`autoRedirectOnAuthenticationComplete is enabled, but the completed-with-${label} step did not include an authorization-response link.`
);
});
});

it('should throw error to the error boundary when links exist but none have rel "authorization-response"', async () => {
render(
<HaapiStepper>
<TestComponent />
</HaapiStepper>
);

await screen.findByTestId('step-type');
await goToNextStep(stepType, { linksWithoutAuthorizationResponse: true });

await waitFor(() => {
expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
'redirectOnAuthenticationCompletedWithSuccess is enabled, but the completed-with-success step did not include an authorization-response link.'
`autoRedirectOnAuthenticationComplete is enabled, but the completed-with-${label} step did not include an authorization-response link.`
);
});
});
Expand All @@ -851,7 +883,7 @@ describe('HaapiStepper', () => {
);

await screen.findByTestId('step-type');
await goToNextStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS);
await goToNextStep(stepType);

await waitFor(() => {
expect(window.location.href).toBe(authorizationResponseUrl);
Expand All @@ -861,23 +893,59 @@ describe('HaapiStepper', () => {
});
});

describe('redirectOnAuthenticationCompletedWithSuccess disabled', () => {
describe('autoRedirectOnAuthenticationComplete disabled', () => {
it('should render the completed step instead of redirecting', async () => {
render(
<HaapiStepper config={{ redirectOnAuthenticationCompletedWithSuccess: false }}>
<HaapiStepper config={{ autoRedirectOnAuthenticationComplete: false }}>
<TestComponent />
</HaapiStepper>
);

await screen.findByTestId('step-type');
await goToNextStep(HAAPI_STEPS.COMPLETED_WITH_SUCCESS);
await goToNextStep(stepType);

await waitFor(() => {
expect(screen.getByTestId('step-type')).toHaveTextContent(HAAPI_STEPS.COMPLETED_WITH_SUCCESS);
expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
});

expect(window.location.href).not.toBe(authorizationResponseUrl);
});

it('should add the completed step to history with the full OAuth payload accessible to consumers', async () => {
render(
<HaapiStepper config={{ autoRedirectOnAuthenticationComplete: false }}>
<TestComponent />
</HaapiStepper>
);

await screen.findByTestId('step-type');
await goToNextStep(stepType);

await waitFor(() => {
expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
});

const historyData = getHistoryData(screen.getByTestId('history'));
const completedHistoryEntry = historyData[
historyData.length - 1
] as HaapiStepperHistoryEntry<HaapiStepperCompletedStep>;

expect(completedHistoryEntry.step.type).toBe(stepType);

if (label === 'success') {
// @ts-expect-error - narrowing the history step union for test access
expect(completedHistoryEntry.step.properties).toMatchObject({
code: 'ziQUB25BIR9xbMLnCK0vetFEsVfYsrl8',
iss: 'https://localhost:8443/dev/oauth/anonymous',
state: 'foo',
});
} else {
// @ts-expect-error - narrowing the history step union for test access
expect(completedHistoryEntry.step.error).toBe('server_error');
// @ts-expect-error - narrowing the history step union for test access
expect(completedHistoryEntry.step.error_description).toBe('An error occurred during authorization');
}
});
});
});
});
Expand Down Expand Up @@ -1321,7 +1389,22 @@ function getStepMock(stepType: HAAPI_STEPS | HAAPI_PROBLEM_STEPS, config?: Recor
}
break;
case HAAPI_STEPS.COMPLETED_WITH_SUCCESS:
stepMock = config?.noLinks ? completedWithSuccessStepWithoutLinks : completedWithSuccessStep;
if (config?.noLinks) {
stepMock = completedWithSuccessStepWithoutLinks;
} else if (config?.linksWithoutAuthorizationResponse) {
stepMock = { ...completedWithSuccessStep, links: [{ href: '/dev/cancel', rel: 'cancel' }] };
} else {
stepMock = completedWithSuccessStep;
}
break;
case HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR:
if (config?.noLinks) {
stepMock = completedWithErrorStepWithoutLinks;
} else if (config?.linksWithoutAuthorizationResponse) {
stepMock = { ...completedWithErrorStep, links: [{ href: '/dev/cancel', rel: 'cancel' }] };
} else {
stepMock = completedWithErrorStep;
}
break;
case HAAPI_STEPS.REGISTRATION:
stepMock = createRegistrationStep();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { formatContinueSameStepData } from './data-formatters/continue-same-step
import { handlePollingStep } from './data-formatters/polling-step';
import { formatErrorStepData } from './data-formatters/problem-step';
import { formatNextStepData } from './data-formatters/format-next-step-data';
import { handleCompletedWithSuccessStep } from './step-handlers/completed-with-success-step';
import { handleCompletedStep } from './step-handlers/completed-step';
import { sendHaapiFetchRequest } from '../../data-access/happi-fetch-request';
import { configuration } from '../../data-access/bootstrap-configuration';
import type {
Expand All @@ -47,15 +47,15 @@ import { handleAuthenticationOrRegistrationStep } from './step-handlers/authenti
const DEFAULT_CONFIG: Required<HaapiStepperConfig> = {
pollingInterval: 3000,
bankIdAutostart: true,
autoRedirectOnAuthenticationComplete: true,
webAuthnAutostart: true,
redirectOnAuthenticationCompletedWithSuccess: true,
};

export interface HaapiStepperConfig {
pollingInterval: number;
bankIdAutostart: boolean;
autoRedirectOnAuthenticationComplete: boolean;
webAuthnAutostart: boolean;
redirectOnAuthenticationCompletedWithSuccess: boolean;
}

interface HaapiStepperProps {
Expand Down Expand Up @@ -411,16 +411,12 @@ async function processHaapiNextStep(
case HAAPI_STEPS.POLLING:
return handlePollingStep(nextStepResponse, pendingOperation, nextStep, config, history);

case HAAPI_STEPS.COMPLETED_WITH_SUCCESS:
return handleCompletedWithSuccessStep(nextStepResponse, config);

case HAAPI_STEPS.AUTHENTICATION:
case HAAPI_STEPS.REGISTRATION:
return handleAuthenticationOrRegistrationStep(nextStepResponse, nextStep, config);

case HAAPI_STEPS.USER_CONSENT:
case HAAPI_STEPS.CONSENTOR:
case HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR:
return { nextStepData: formatNextStepData(nextStepResponse) };

case HAAPI_STEPS.CONTINUE_SAME:
Expand All @@ -429,6 +425,10 @@ async function processHaapiNextStep(
}
return { nextStepData: formatContinueSameStepData(action, nextStepResponse, currentStep as HaapiStepperStep) };

case HAAPI_STEPS.COMPLETED_WITH_SUCCESS:
case HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR:
return handleCompletedStep(nextStepResponse, config);

case HAAPI_PROBLEM_STEPS.INVALID_INPUT:
case HAAPI_PROBLEM_STEPS.INCORRECT_CREDENTIALS:
case HAAPI_PROBLEM_STEPS.AUTHENTICATION_FAILED:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 { HAAPI_STEPS, type HaapiCompletedStep } from '../../../data-access/types/haapi-step.types';
import type { HaapiStepperConfig } from '../HaapiStepper';
import { formatNextStepData } from '../data-formatters/format-next-step-data';

export function handleCompletedStep(step: HaapiCompletedStep, config: HaapiStepperConfig) {
if (!config.autoRedirectOnAuthenticationComplete) {
return { nextStepData: formatNextStepData(step) };
}

const redirectHref = step.links?.find(link => link.rel === 'authorization-response')?.href;

if (!redirectHref) {
const isSuccess = step.type === HAAPI_STEPS.COMPLETED_WITH_SUCCESS;
throw new Error(
`autoRedirectOnAuthenticationComplete is enabled, but the completed-with-${isSuccess ? 'success' : 'error'} step did not include an authorization-response link.`
);
}

window.location.href = redirectHref;
return { nextStepData: undefined };
}

This file was deleted.

19 changes: 19 additions & 0 deletions src/login-web-app/src/shared/util/api-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,25 @@ export const completedWithSuccessStepWithoutLinks = {
} as HaapiCompletedWithSuccessStep;

export const completedWithErrorStep = {
type: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
title: 'Authorization Error',
messages: [
{
text: 'The authorization process completed with an error.',
classList: ['error'],
},
],
links: [
{
href: 'http://client-callback?error=server_error',
rel: 'authorization-response',
},
],
error: 'server_error',
error_description: 'An error occurred during authorization',
} as HaapiCompletedWithErrorStep;

export const completedWithErrorStepWithoutLinks = {
type: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
title: 'Authorization Error',
messages: [
Expand Down
Loading