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 */