From a964b65e893324c3a99aa8c1920114530513a2f5 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 19 Jun 2026 19:22:34 +0000 Subject: [PATCH] fix(authFlows): persist PUZZLE/FACE step config in the flow builder The builder rendered the PUZZLE layer config (allowed challenge types, count, difficulty, alsoMatchFaceIdentity) and the FACE active-puzzle-liveness toggle, but the save path never serialized them into FlowStepSpec.config and the edit-load path never parsed them back. A tenant's puzzle configuration was thus silently dropped on save, and the runtime then rejected the step as unconfigured. Add a pure, tested stepConfig module (serializeStepConfig/parseStepConfig) and wire it into AuthFlowsPage handleSave (serialize) and getBuilderInitialSteps (parse), so the config round-trips create -> get faithfully, matching the backend AuthFlowPuzzleConfigRoundTripTest blob shape. - serialize on save: PUZZLE -> puzzleConfig JSON; FACE -> {requireActivePuzzleLiveness} - parse on edit-load: hydrate both back so re-save does not drop them - 10 unit tests; tolerant of empty/malformed/absent config --- .../authFlows/__tests__/stepConfig.test.ts | 93 +++++++++++++++++++ .../authFlows/components/AuthFlowsPage.tsx | 67 +++++++------ src/features/authFlows/stepConfig.ts | 77 +++++++++++++++ 3 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 src/features/authFlows/__tests__/stepConfig.test.ts create mode 100644 src/features/authFlows/stepConfig.ts diff --git a/src/features/authFlows/__tests__/stepConfig.test.ts b/src/features/authFlows/__tests__/stepConfig.test.ts new file mode 100644 index 0000000..07d30c4 --- /dev/null +++ b/src/features/authFlows/__tests__/stepConfig.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { AuthMethodType, type AuthFlowStep, type PuzzleConfig } from '@domain/models/AuthMethod' +import { serializeStepConfig, parseStepConfig } from '../stepConfig' + +const PUZZLE_CONFIG: PuzzleConfig = { + allowedChallengeTypes: ['OBJECT_MATCH', 'COUNT'], + count: 3, + difficulty: 'medium', + alsoMatchFaceIdentity: false, +} + +function step(partial: Partial): AuthFlowStep { + return { + id: 's1', + order: 1, + methodId: 'm', + methodType: AuthMethodType.PASSWORD, + isRequired: true, + timeout: 120, + maxAttempts: 3, + ...partial, + } +} + +describe('serializeStepConfig', () => { + it('serializes a PUZZLE step puzzleConfig as the backend-shaped JSON blob', () => { + const out = serializeStepConfig(step({ methodType: AuthMethodType.PUZZLE, puzzleConfig: PUZZLE_CONFIG })) + // must match the round-trip-tested backend shape exactly + expect(out).toBe( + '{"allowedChallengeTypes":["OBJECT_MATCH","COUNT"],"count":3,"difficulty":"medium","alsoMatchFaceIdentity":false}', + ) + }) + + it('serializes PUZZLE when it is a CHOICE alternative, not just the primary method', () => { + const out = serializeStepConfig( + step({ + methodType: AuthMethodType.FACE, + alternativeMethodTypes: [AuthMethodType.PUZZLE], + puzzleConfig: PUZZLE_CONFIG, + }), + ) + expect(out).toContain('allowedChallengeTypes') + }) + + it('serializes a FACE step active-puzzle-liveness toggle', () => { + const out = serializeStepConfig( + step({ methodType: AuthMethodType.FACE, requireActivePuzzleLiveness: true }), + ) + expect(out).toBe('{"requireActivePuzzleLiveness":true}') + }) + + it('returns undefined for an ordinary step (payload shape unchanged)', () => { + expect(serializeStepConfig(step({ methodType: AuthMethodType.PASSWORD }))).toBeUndefined() + }) + + it('returns undefined for a PUZZLE step that has no puzzleConfig yet', () => { + expect(serializeStepConfig(step({ methodType: AuthMethodType.PUZZLE }))).toBeUndefined() + }) +}) + +describe('parseStepConfig', () => { + it('returns {} for undefined / empty / malformed config', () => { + expect(parseStepConfig(undefined)).toEqual({}) + expect(parseStepConfig('{}')).toEqual({}) + expect(parseStepConfig('not json')).toEqual({}) + }) + + it('parses a backend puzzle blob into puzzleConfig', () => { + const parsed = parseStepConfig( + '{"allowedChallengeTypes":["OBJECT_MATCH","COUNT"],"count":3,"difficulty":"medium","alsoMatchFaceIdentity":false}', + ) + expect(parsed.puzzleConfig).toEqual(PUZZLE_CONFIG) + }) + + it('parses a FACE blob into requireActivePuzzleLiveness', () => { + expect(parseStepConfig('{"requireActivePuzzleLiveness":true}')).toEqual({ + requireActivePuzzleLiveness: true, + }) + }) + + it('defaults alsoMatchFaceIdentity to true unless explicitly false', () => { + const parsed = parseStepConfig('{"allowedChallengeTypes":["COUNT"],"count":2,"difficulty":"easy"}') + expect(parsed.puzzleConfig?.alsoMatchFaceIdentity).toBe(true) + expect(parsed.puzzleConfig?.difficulty).toBe('easy') + }) +}) + +describe('round-trip', () => { + it('serialize → parse preserves a puzzleConfig', () => { + const blob = serializeStepConfig(step({ methodType: AuthMethodType.PUZZLE, puzzleConfig: PUZZLE_CONFIG })) + expect(parseStepConfig(blob).puzzleConfig).toEqual(PUZZLE_CONFIG) + }) +}) diff --git a/src/features/authFlows/components/AuthFlowsPage.tsx b/src/features/authFlows/components/AuthFlowsPage.tsx index 4f0332b..5f125c3 100644 --- a/src/features/authFlows/components/AuthFlowsPage.tsx +++ b/src/features/authFlows/components/AuthFlowsPage.tsx @@ -44,6 +44,7 @@ import { type AuthFlowStep, } from '@domain/models/AuthMethod' import type { AuthFlowRepository, AuthFlowResponse, CreateAuthFlowCommand, AuthFlowDefaultImpact } from '@core/repositories/AuthFlowRepository' +import { serializeStepConfig, parseStepConfig } from '../stepConfig' import type { ILogger } from '@domain/interfaces/ILogger' import { formatApiError } from '@utils/formatApiError' import { useTranslation } from 'react-i18next' @@ -148,20 +149,27 @@ export default function AuthFlowsPage() { description: data.description, operationType: data.operationType, isDefault: data.isDefault, - steps: data.steps.map((s) => ({ - authMethodType: s.methodType.toUpperCase(), - stepOrder: s.order, - isRequired: s.isRequired, - timeoutSeconds: s.timeout, - maxAttempts: s.maxAttempts, - allowsDelegation: false, - // CHOICE + usernameless (E): persist only when set so legacy - // single-method strict steps keep the same payload shape. - ...(s.alternativeMethodTypes && s.alternativeMethodTypes.length > 0 - ? { alternativeMethodTypes: s.alternativeMethodTypes.map((m) => m.toUpperCase()) } - : {}), - ...(s.usernameless ? { usernameless: true } : {}), - })), + steps: data.steps.map((s) => { + // Serialize per-step config (PUZZLE puzzleConfig / FACE active-puzzle- + // liveness) into FlowStepSpec.config. Without this the builder's puzzle + // selection/count/difficulty and the FACE toggle were dropped on save. + const config = serializeStepConfig(s) + return { + authMethodType: s.methodType.toUpperCase(), + stepOrder: s.order, + isRequired: s.isRequired, + timeoutSeconds: s.timeout, + maxAttempts: s.maxAttempts, + allowsDelegation: false, + ...(config ? { config } : {}), + // CHOICE + usernameless (E): persist only when set so legacy + // single-method strict steps keep the same payload shape. + ...(s.alternativeMethodTypes && s.alternativeMethodTypes.length > 0 + ? { alternativeMethodTypes: s.alternativeMethodTypes.map((m) => m.toUpperCase()) } + : {}), + ...(s.usernameless ? { usernameless: true } : {}), + } + }), } if (editingFlow) { @@ -313,18 +321,25 @@ export default function AuthFlowsPage() { // Convert AuthFlowResponse steps to AuthFlowStep[] for the builder const getBuilderInitialSteps = (): AuthFlowStep[] => { if (!editingFlow?.steps) return [] - return editingFlow.steps.map((s, i) => ({ - id: `step-${i}-${Date.now()}`, - order: s.stepOrder, - methodId: s.authMethodType, - methodType: s.authMethodType as unknown as AuthFlowStep['methodType'], - isRequired: s.isRequired, - timeout: s.timeoutSeconds ?? 120, - maxAttempts: s.maxAttempts ?? 3, - // Hydrate CHOICE alternatives + usernameless flag (E) when present. - alternativeMethodTypes: (s.alternativeMethodTypes ?? []) as unknown as AuthFlowStep['alternativeMethodTypes'], - usernameless: s.usernameless ?? false, - })) + return editingFlow.steps.map((s, i) => { + // Hydrate PUZZLE puzzleConfig / FACE active-puzzle-liveness back from the + // persisted config blob so editing a flow does not drop them on re-save. + const { puzzleConfig, requireActivePuzzleLiveness } = parseStepConfig(s.config) + return { + id: `step-${i}-${Date.now()}`, + order: s.stepOrder, + methodId: s.authMethodType, + methodType: s.authMethodType as unknown as AuthFlowStep['methodType'], + isRequired: s.isRequired, + timeout: s.timeoutSeconds ?? 120, + maxAttempts: s.maxAttempts ?? 3, + // Hydrate CHOICE alternatives + usernameless flag (E) when present. + alternativeMethodTypes: (s.alternativeMethodTypes ?? []) as unknown as AuthFlowStep['alternativeMethodTypes'], + usernameless: s.usernameless ?? false, + ...(puzzleConfig ? { puzzleConfig } : {}), + ...(requireActivePuzzleLiveness ? { requireActivePuzzleLiveness: true } : {}), + } + }) } return ( diff --git a/src/features/authFlows/stepConfig.ts b/src/features/authFlows/stepConfig.ts new file mode 100644 index 0000000..c13ce1b --- /dev/null +++ b/src/features/authFlows/stepConfig.ts @@ -0,0 +1,77 @@ +import { AuthMethodType, type AuthFlowStep, type PuzzleConfig } from '@domain/models/AuthMethod' + +/** + * Per-step config serialization for the auth-flow builder. + * + * The backend persists arbitrary per-step configuration as a JSON blob in + * `auth_flow_steps.config` (surfaced on `FlowStepSpec.config`). Two builder + * controls write into it: a PUZZLE layer's {@link PuzzleConfig} and a FACE + * layer's active-puzzle-liveness toggle. Before this module those controls were + * rendered and edited but never serialized on save, so the tenant's puzzle + * selection / count / difficulty (and the FACE toggle) were silently dropped and + * the runtime then rejected the step as unconfigured. These helpers serialize on + * save and parse back when a flow is opened for editing, so the config + * round-trips through create → get faithfully. + */ + +type StepConfigFields = Pick< + AuthFlowStep, + 'methodType' | 'alternativeMethodTypes' | 'puzzleConfig' | 'requireActivePuzzleLiveness' +> + +/** Methods that participate in a step (primary + CHOICE alternatives). */ +function stepMethods(step: StepConfigFields): AuthMethodType[] { + return [step.methodType, ...(step.alternativeMethodTypes ?? [])] +} + +/** + * Serialize a builder step's PUZZLE/FACE config into the `FlowStepSpec.config` + * JSON string. Returns `undefined` for ordinary steps (or unconfigured ones), so + * the payload shape is unchanged when there is nothing to persist. + */ +export function serializeStepConfig(step: StepConfigFields): string | undefined { + const methods = stepMethods(step) + if (methods.includes(AuthMethodType.PUZZLE) && step.puzzleConfig) { + return JSON.stringify(step.puzzleConfig) + } + if (methods.includes(AuthMethodType.FACE) && step.requireActivePuzzleLiveness) { + return JSON.stringify({ requireActivePuzzleLiveness: true }) + } + return undefined +} + +/** + * Parse a `FlowStepSpec.config` blob back into the builder fields it serialized. + * Tolerant of `undefined` / empty `"{}"` / malformed JSON (returns `{}`), so an + * unexpected blob never breaks opening a flow for editing. + */ +export function parseStepConfig(config?: string): { + puzzleConfig?: PuzzleConfig + requireActivePuzzleLiveness?: boolean +} { + if (!config) return {} + let raw: Record + try { + raw = JSON.parse(config) as Record + } catch { + return {} + } + if (!raw || typeof raw !== 'object') return {} + + const out: { puzzleConfig?: PuzzleConfig; requireActivePuzzleLiveness?: boolean } = {} + + if (Array.isArray(raw.allowedChallengeTypes)) { + const difficulty = raw.difficulty + out.puzzleConfig = { + allowedChallengeTypes: (raw.allowedChallengeTypes as unknown[]).map(String), + count: typeof raw.count === 'number' && raw.count >= 1 ? raw.count : 2, + difficulty: difficulty === 'easy' || difficulty === 'hard' ? difficulty : 'medium', + // defaults to the higher-assurance binding unless explicitly disabled + alsoMatchFaceIdentity: raw.alsoMatchFaceIdentity !== false, + } + } + if (raw.requireActivePuzzleLiveness === true) { + out.requireActivePuzzleLiveness = true + } + return out +}