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
93 changes: 93 additions & 0 deletions src/features/authFlows/__tests__/stepConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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)
})
})
67 changes: 41 additions & 26 deletions src/features/authFlows/components/AuthFlowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down
77 changes: 77 additions & 0 deletions src/features/authFlows/stepConfig.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
try {
raw = JSON.parse(config) as Record<string, unknown>
} 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
}
Loading