diff --git a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types.ts b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types.ts
index 208a9d1b..868b5a6d 100644
--- a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types.ts
+++ b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types.ts
@@ -92,6 +92,7 @@ export enum HAAPI_FORM_ACTION_KINDS {
AUTHENTICATOR_SELECTOR_OPTION = 'select-authenticator',
CONTINUE = 'continue',
USER_REGISTER = 'user-register',
+ AUTHORIZATION_RESPONSE = 'authorization-response',
}
export enum HAAPI_ACTION_CLIENT_OPERATIONS {
diff --git a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts
index 7569c918..09d5a1b3 100644
--- a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts
+++ b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts
@@ -217,11 +217,10 @@ export interface HaapiCompletedWithSuccessStep extends HaapiBaseStep {
properties: HaapiCompletedWithSuccessStepOAuthAuthorizationResponseProperties;
/** User messages. Should be displayed to users to help them understand the context of an interaction. */
messages?: HaapiUserMessage[];
- /**
- * Array of hyperlinks that may be used to offer an user alternative flows. The "main" flow the user is expected to take should use "actions" instead.
- * Links can be used, for example, to divert the user to register an account or a device from a login page.
- */
+ /** If the flow response mode is redirect/GET-based, contains a ready-to-use link with the flow response. */
links?: HaapiLink[];
+ /** If the flow response mode is form POST, contains a ready-to-use form action with the flow response. */
+ actions?: HaapiFormAction[];
}
/**
@@ -275,6 +274,8 @@ export interface HaapiCompletedWithErrorStep extends HaapiProblemStepBase {
error_description?: string;
/** Issuer identifier */
iss?: string;
+ /** If the flow response mode is form POST, contains a ready-to-use form action with the flow response. */
+ actions?: HaapiFormAction[];
}
/**
diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx
index 5d0427e8..e3687431 100644
--- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx
+++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx
@@ -21,9 +21,11 @@ import { MEDIA_TYPES } from '../../data-access/types/media.types';
import {
authenticationStep,
completedWithErrorStep,
- completedWithErrorStepWithoutLinks,
+ completedWithErrorStepFormPost,
+ completedWithErrorStepWithoutLinksOrActions,
completedWithSuccessStep,
- completedWithSuccessStepWithoutLinks,
+ completedWithSuccessStepFormPost,
+ completedWithSuccessStepWithoutLinksOrActions,
continueSameStep,
createProblemStep,
createRegistrationStep,
@@ -42,6 +44,7 @@ import type {
import { HaapiStepperActionStep, HaapiStepperFormAction } from './haapi-stepper.types';
import { isQrCodeLink } from '../../util/link-predicates';
import {
+ createMockFormAction,
createMockWebAuthnAnyDeviceBothOptionsAction,
createMockWebAuthnAuthenticationAction,
createMockWebAuthnCrossPlatformOnlyAnyDeviceAction,
@@ -898,71 +901,135 @@ describe('HaapiStepper', () => {
});
});
- 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 }) => {
- 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(
-
-
-
- );
+ describe('Completed Step', () => {
+ describe.each([
+ {
+ label: 'success - redirect',
+ stepType: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
+ },
+ {
+ label: 'error - redirect',
+ stepType: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
+ },
+ {
+ label: 'success - Form POST',
+ formPostResponseMode: true,
+ stepType: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
+ },
+ {
+ label: 'error - Form POST',
+ formPostResponseMode: true,
+ stepType: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
+ },
+ ])('Common - $label', ({ label, stepType, formPostResponseMode }) => {
+ describe('autoRedirectOnAuthenticationComplete enabled (default)', () => {
+ it('should throw error to the error boundary when no authorization-response link or action exists', async () => {
+ render(
+
+
+
+ );
- await screen.findByTestId('step-type');
- await goToNextStep(stepType);
+ await screen.findByTestId('step-type');
+ await goToNextStep(stepType, { noLinksOrActions: true, formPostResponseMode });
- await waitFor(() => {
- expect(window.location.href).toBe(authorizationResponseUrl);
+ await waitFor(() => {
+ expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
+ `autoRedirectOnAuthenticationComplete is enabled, but the '${stepType}' step did not include an authorization-response link or action.`
+ );
+ });
});
- });
- it('should throw error to the error boundary when no authorization-response link exists', async () => {
- render(
-
-
-
- );
+ it('should throw error to the error boundary when links or actions exist but none have rel/kind "authorization-response"', async () => {
+ render(
+
+
+
+ );
- await screen.findByTestId('step-type');
- await goToNextStep(stepType, { noLinks: true });
+ await screen.findByTestId('step-type');
+ await goToNextStep(stepType, { formPostResponseMode, withoutAuthorizationResponseLinkOrAction: true });
- await waitFor(() => {
- expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
- `autoRedirectOnAuthenticationComplete is enabled, but the completed-with-${label} step did not include an authorization-response link.`
- );
+ await waitFor(() => {
+ expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
+ `autoRedirectOnAuthenticationComplete is enabled, but the '${stepType}' step did not include an authorization-response link or action.`
+ );
+ });
});
});
- it('should throw error to the error boundary when links exist but none have rel "authorization-response"', async () => {
- render(
-
-
-
- );
+ describe('autoRedirectOnAuthenticationComplete disabled', () => {
+ it('should render the completed step instead of redirecting', async () => {
+ const initialWindowLocation = window.location.href;
+ render(
+
+
+
+ );
- await screen.findByTestId('step-type');
- await goToNextStep(stepType, { linksWithoutAuthorizationResponse: true });
+ await screen.findByTestId('step-type');
+ await goToNextStep(stepType, { formPostResponseMode });
- await waitFor(() => {
- expect(mockThrowErrorToAppErrorBoundary).toHaveBeenCalledWith(
- `autoRedirectOnAuthenticationComplete is enabled, but the completed-with-${label} step did not include an authorization-response link.`
+ await waitFor(() => {
+ expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
+ });
+
+ expect(window.location.href).toBe(initialWindowLocation);
+ });
+
+ it('should add the completed step to history with the full OAuth payload accessible to consumers', async () => {
+ render(
+
+
+
);
+
+ await screen.findByTestId('step-type');
+ await goToNextStep(stepType, { formPostResponseMode });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
+ });
+
+ const historyData = getHistoryData(screen.getByTestId('history'));
+ const completedHistoryEntry = historyData[
+ historyData.length - 1
+ ] as HaapiStepperHistoryEntry;
+
+ expect(completedHistoryEntry.step.type).toBe(stepType);
+
+ if (label.startsWith('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');
+ }
});
});
+ });
+
+ 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)('Redirect/GET - $label', ({ stepType, stepFixture }) => {
+ const authorizationResponseUrl = stepFixture.links?.find(link => link.rel === 'authorization-response')?.href;
- it('should not update the current step when redirecting', async () => {
+ it('should redirect to the authorization-response URL', async () => {
render(
@@ -976,62 +1043,63 @@ describe('HaapiStepper', () => {
expect(window.location.href).toBe(authorizationResponseUrl);
});
+ // current step is not updated
expect(screen.getByTestId('step-type')).toHaveTextContent(initialStepType);
});
});
- describe('autoRedirectOnAuthenticationComplete disabled', () => {
- it('should render the completed step instead of redirecting', async () => {
- render(
-
-
-
- );
+ describe.each([
+ {
+ label: 'success',
+ stepType: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
+ stepFixture: completedWithSuccessStepFormPost,
+ },
+ {
+ label: 'error',
+ stepType: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
+ stepFixture: completedWithErrorStepFormPost,
+ },
+ ] as const)('Form POST - $label', ({ stepType, stepFixture }) => {
+ const formModel = stepFixture.actions[0].model;
- await screen.findByTestId('step-type');
- await goToNextStep(stepType);
-
- await waitFor(() => {
- expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
- });
-
- expect(window.location.href).not.toBe(authorizationResponseUrl);
+ afterEach(() => {
+ // The handler appends the auto-submit form directly to document.body (outside the
+ // React render container), so Testing Library's cleanup does not remove it.
+ document.body.querySelectorAll('form').forEach(form => form.remove());
});
- it('should add the completed step to history with the full OAuth payload accessible to consumers', async () => {
+ it('POSTs a form to the authorization-response URL', async () => {
+ const submitSpy = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => undefined);
+
render(
-
+
);
await screen.findByTestId('step-type');
- await goToNextStep(stepType);
+ await goToNextStep(stepType, { formPostResponseMode: true });
await waitFor(() => {
- expect(screen.getByTestId('step-type')).toHaveTextContent(stepType);
+ expect(submitSpy).toHaveBeenCalledTimes(1);
});
- const historyData = getHistoryData(screen.getByTestId('history'));
- const completedHistoryEntry = historyData[
- historyData.length - 1
- ] as HaapiStepperHistoryEntry;
-
- expect(completedHistoryEntry.step.type).toBe(stepType);
+ // current step is not updated
+ expect(screen.getByTestId('step-type')).toHaveTextContent(initialStepType);
- 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');
- }
+ const form = document.body.querySelector('form');
+ expect(form).not.toBeNull();
+ expect(form!.getAttribute('action')).toBe(formModel.href);
+ expect(form!.method.toUpperCase()).toBe(formModel.method);
+
+ const inputs = Array.from(form!.querySelectorAll('input'));
+ expect(inputs).toHaveLength(formModel.fields.length);
+ inputs.forEach((input, index) => {
+ const field = formModel.fields[index];
+ expect(input.type).toBe('hidden');
+ expect(input.name).toBe(field.name);
+ expect(input.value).toBe(field.value);
+ });
});
});
});
@@ -1473,21 +1541,33 @@ function getStepMock(stepType: HAAPI_STEPS | HAAPI_PROBLEM_STEPS, config?: Recor
}
break;
case HAAPI_STEPS.COMPLETED_WITH_SUCCESS:
- if (config?.noLinks) {
- stepMock = completedWithSuccessStepWithoutLinks;
- } else if (config?.linksWithoutAuthorizationResponse) {
- stepMock = { ...completedWithSuccessStep, links: [{ href: '/dev/cancel', rel: 'cancel' }] };
+ if (config?.noLinksOrActions) {
+ stepMock = completedWithSuccessStepWithoutLinksOrActions;
+ } else if (config?.formPostResponseMode) {
+ stepMock = completedWithSuccessStepFormPost;
+ if (config.withoutAuthorizationResponseLinkOrAction) {
+ stepMock = { ...stepMock, actions: [createMockFormAction()] };
+ }
} else {
stepMock = completedWithSuccessStep;
+ if (config?.withoutAuthorizationResponseLinkOrAction) {
+ stepMock = { ...stepMock, links: [{ href: '/dev/cancel', rel: 'cancel' }] };
+ }
}
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' }] };
+ if (config?.noLinksOrActions) {
+ stepMock = completedWithErrorStepWithoutLinksOrActions;
+ } else if (config?.formPostResponseMode) {
+ stepMock = completedWithErrorStepFormPost;
+ if (config.withoutAuthorizationResponseLinkOrAction) {
+ stepMock = { ...stepMock, actions: [createMockFormAction()] };
+ }
} else {
stepMock = completedWithErrorStep;
+ if (config?.withoutAuthorizationResponseLinkOrAction) {
+ stepMock = { ...stepMock, links: [{ href: '/dev/cancel', rel: 'cancel' }] };
+ }
}
break;
case HAAPI_STEPS.REGISTRATION:
diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/completed-step.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/completed-step.ts
index ace7d7dc..d9216907 100644
--- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/completed-step.ts
+++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/completed-step.ts
@@ -9,24 +9,74 @@
* For further information, please contact Curity AB.
*/
-import { HAAPI_STEPS, type HaapiCompletedStep } from '../../../data-access/types/haapi-step.types';
+import { type HaapiCompletedStep } from '../../../data-access/types/haapi-step.types';
+import { HAAPI_FORM_ACTION_KINDS, type HaapiFormAction } from '../../../data-access/types/haapi-action.types';
import type { HaapiStepperConfig } from '../haapi-stepper.types';
import { formatNextStepData } from '../data-formatters/format-next-step-data';
+/**
+ * Handles a terminal step by redirecting back to the client application that initiated the flow (when configured to
+ * automatically do so).
+ * This is always done via a top-level navigation, meaning that the current application will be unloaded. That's why
+ * the window/document are modified directly, instead of rendering a component and using effects.
+ */
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 (tryHandleRedirectResponse(step) || tryHandleFormPostResponse(step)) {
+ return { nextStepData: undefined };
+ }
+
+ throw new Error(
+ `autoRedirectOnAuthenticationComplete is enabled, but the '${step.type}' step did not include an authorization-response link or action.`
+ );
+}
- 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.`
- );
+/**
+ * Response modes that use redirect/GET.
+ */
+function tryHandleRedirectResponse(step: HaapiCompletedStep): boolean {
+ const responseLink = step.links?.find(link => link.rel === 'authorization-response');
+ if (responseLink) {
+ window.location.href = responseLink.href;
+ return true;
}
- window.location.href = redirectHref;
- return { nextStepData: undefined };
+ return false;
+}
+
+/**
+ * Form POST response mode.
+ */
+function tryHandleFormPostResponse(step: HaapiCompletedStep): boolean {
+ const responseAction = step.actions?.find(action => action.kind === HAAPI_FORM_ACTION_KINDS.AUTHORIZATION_RESPONSE);
+ if (responseAction) {
+ const htmlForm = createHtmlForm(responseAction);
+ document.body.appendChild(htmlForm);
+ htmlForm.submit();
+ return true;
+ }
+
+ return false;
+}
+
+function createHtmlForm(action: HaapiFormAction): HTMLFormElement {
+ const { href, method, fields } = action.model;
+
+ const form = document.createElement('form');
+ form.method = method;
+ form.action = href;
+ form.classList.add('hidden');
+
+ (fields ?? []).forEach(field => {
+ const input = document.createElement('input');
+ input.type = field.type;
+ input.name = field.name;
+ input.value = field.value ?? '';
+ form.appendChild(input);
+ });
+
+ return form;
}
diff --git a/src/haapi-react-sdk/haapi-stepper/util/tests/api-responses.ts b/src/haapi-react-sdk/haapi-stepper/util/tests/api-responses.ts
index 7cfe9576..d35a2875 100644
--- a/src/haapi-react-sdk/haapi-stepper/util/tests/api-responses.ts
+++ b/src/haapi-react-sdk/haapi-stepper/util/tests/api-responses.ts
@@ -307,7 +307,7 @@ export const completedWithSuccessStep = {
},
} as HaapiCompletedWithSuccessStep;
-export const completedWithSuccessStepWithoutLinks = {
+export const completedWithSuccessStepWithoutLinksOrActions = {
type: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
metadata: {
viewName: 'templates/oauth/success-authorization-response',
@@ -338,7 +338,7 @@ export const completedWithErrorStep = {
error_description: 'An error occurred during authorization',
} as HaapiCompletedWithErrorStep;
-export const completedWithErrorStepWithoutLinks = {
+export const completedWithErrorStepWithoutLinksOrActions = {
type: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
title: 'Authorization Error',
messages: [
@@ -351,6 +351,67 @@ export const completedWithErrorStepWithoutLinks = {
error_description: 'An error occurred during authorization',
} as HaapiCompletedWithErrorStep;
+export const completedWithSuccessStepFormPost = {
+ type: HAAPI_STEPS.COMPLETED_WITH_SUCCESS,
+ actions: [
+ {
+ template: HAAPI_ACTION_TYPES.FORM,
+ kind: 'authorization-response',
+ model: {
+ href: 'http://client-callback',
+ method: HTTP_METHODS.POST,
+ type: MEDIA_TYPES.FORM_URLENCODED,
+ fields: [
+ { name: 'code', type: HAAPI_FORM_FIELDS.HIDDEN, value: 'ziQUB25BIR9xbMLnCK0vetFEsVfYsrl8' },
+ { name: 'iss', type: HAAPI_FORM_FIELDS.HIDDEN, value: 'https://localhost:8443/dev/oauth/anonymous' },
+ { name: 'state', type: HAAPI_FORM_FIELDS.HIDDEN, value: 'foo' },
+ ],
+ },
+ },
+ ],
+ metadata: {
+ viewName: 'templates/oauth/success-authorization-response',
+ },
+ properties: {
+ code: 'ziQUB25BIR9xbMLnCK0vetFEsVfYsrl8',
+ iss: 'https://localhost:8443/dev/oauth/anonymous',
+ state: 'foo',
+ },
+} satisfies HaapiCompletedWithSuccessStep;
+
+export const completedWithErrorStepFormPost = {
+ type: HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR,
+ title: 'Authorization Error',
+ messages: [
+ {
+ text: 'The authorization process completed with an error.',
+ classList: ['error'],
+ },
+ ],
+ actions: [
+ {
+ template: HAAPI_ACTION_TYPES.FORM,
+ kind: 'authorization-response',
+ model: {
+ href: 'http://client-callback',
+ method: HTTP_METHODS.POST,
+ type: MEDIA_TYPES.FORM_URLENCODED,
+ fields: [
+ { name: 'error', type: HAAPI_FORM_FIELDS.HIDDEN, value: 'server_error' },
+ {
+ name: 'error_description',
+ type: HAAPI_FORM_FIELDS.HIDDEN,
+ value: 'An error occurred during authorization',
+ },
+ { name: 'iss', type: HAAPI_FORM_FIELDS.HIDDEN, value: 'https://localhost:8443/dev/oauth/anonymous' },
+ ],
+ },
+ },
+ ],
+ error: 'server_error',
+ error_description: 'An error occurred during authorization',
+} satisfies HaapiCompletedWithErrorStep;
+
/**
* Create a ProblemStep instance by problem type with proper typing
*/