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
36 changes: 36 additions & 0 deletions packages/javascript/src/constants/ConsentConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

/**
* Constants describing why a set of consent decisions was submitted.
*
* @remarks
* The reason travels on `ConsentDecisions.reason` and explains a submission the server cannot infer
* from the decisions alone. It is omitted when the user approves.
*
* @example
* ```typescript
* const decisions: ConsentDecisions = {
* approved: false,
* reason: ConsentConstants.REASON_TIMEOUT,
* purposes: [],
* };
* ```
*/
const ConsentConstants: {
REASON_TIMEOUT: string;
REASON_USER_DENIED: string;
} = {
/**
* The prompt expired before the user acted on it.
* The submission is automatic, so the server discards the decisions and records nothing.
*/
REASON_TIMEOUT: 'timeout',

/**
* The user declined the prompt through a deny action.
*/
REASON_USER_DENIED: 'user_denied',
};

export default ConsentConstants;
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {default as ApplicationNativeAuthenticationConstants} from './constants/A
export {default as TokenConstants} from './constants/TokenConstants';
export {default as OIDCRequestConstants} from './constants/OIDCRequestConstants';
export {default as VendorConstants} from './constants/VendorConstants';
export {default as ConsentConstants} from './constants/ConsentConstants';

export {default as ThunderIDError} from './errors/ThunderIDError';
export {default as ThunderIDAPIError} from './errors/ThunderIDAPIError';
Expand Down
8 changes: 8 additions & 0 deletions packages/javascript/src/models/embedded-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,16 @@ export interface ConsentPurposeDecision {
* @experimental This interface may change in future versions
*/
export interface ConsentDecisions {
/**
* Whether the user approved the consent as a whole. Approval is hierarchical: a denial here
* denies every purpose and element below it, while an approval leaves their own decisions
* intact.
*/
approved: boolean;
/** Array of per-purpose decisions */
purposes: ConsentPurposeDecision[];
/** Why the decision was made, when it was not a direct user choice, e.g. the prompt expired */
reason?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {cx} from '@emotion/css';
import {
ConsentConstants,
FieldError,
FlowExecutionError,
FlowMetadataResponse,
Expand Down Expand Up @@ -552,14 +553,16 @@ const BaseAcceptInvite: FC<BaseAcceptInviteProps> = ({
const purposes: any[] = typeof raw === 'string' ? JSON.parse(raw) : raw.purposes || raw;
const isDeny = component?.variant?.toLowerCase() !== 'primary';
const decisions = {
approved: !isDeny,
...(isDeny ? {reason: ConsentConstants.REASON_USER_DENIED} : {}),
purposes: purposes.map((p: any) => ({
approved: !isDeny,
purposeName: p.purposeName,
elements: [
...(p.essential || []).map((e: any) => ({approved: !isDeny, name: e.name})),
...(p.optional || []).map((e: any) => {
const key = `__consent_opt__${p.purposeId}__${e.name}`;
return {approved: isDeny ? false : inputs[key] !== 'false', name: e.name};
return {approved: !isDeny && inputs[key] === 'true', name: e.name};
}),
],
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {css, cx} from '@emotion/css';
import {
ConsentConstants,
FieldType,
FlowMetadataResponse,
EmbeddedFlowComponent,
Expand Down Expand Up @@ -375,12 +376,15 @@ const createAuthComponentFromFlow = (
if (consentPrompt && eventType.toUpperCase() === EmbeddedFlowEventType.Submit) {
const isDeny: boolean = componentVariant.toLowerCase() !== 'primary';
const decisions: ConsentDecisions = {
approved: !isDeny,
...(isDeny ? {reason: ConsentConstants.REASON_USER_DENIED} : {}),
purposes: consentPrompt.purposes.map(
(p: ConsentPurposeData): ConsentPurposeDecision => ({
approved: !isDeny,
elements: [
...p.essential.map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})),
...p.optional.map(
// Permission purposes carry no essential elements, so the server sends null here
...(p.essential ?? []).map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})),
...(p.optional ?? []).map(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(e): ConsentAttributeElement => ({
approved: !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true',
name: e.name,
Expand Down
131 changes: 101 additions & 30 deletions packages/react/src/components/presentation/auth/SignIn/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
ThunderIDRuntimeError,
ThunderIDAPIError,
ConsentConstants,
EmbeddedFlowComponent,
EmbeddedFlowType,
EmbeddedSignInFlowResponse,
Expand Down Expand Up @@ -150,6 +151,32 @@ interface PasskeyState {
isActive: boolean;
}

/**
* Sentinel input key carrying the reason a consent prompt was submitted. It is folded into the
* compiled `consent_decisions` payload and stripped from the inputs, like `__consent_opt__` keys.
*/
const CONSENT_REASON_KEY = '__consent_reason__';

/** Flattens a component tree into a single list, parents before their children. */
const flattenComponents = (comps: EmbeddedFlowComponent[] | undefined): EmbeddedFlowComponent[] =>
(comps ?? []).flatMap((comp: EmbeddedFlowComponent) => [comp, ...flattenComponents(comp.components)]);

/**
* Finds the action to submit an expired consent prompt with. A non-primary submit action is
* preferred so the payload reads as a denial, falling back to any submit action because the server
* needs an action to route the prompt node. Returns undefined when the view has neither.
*/
const findConsentSubmitActionId = (comps: EmbeddedFlowComponent[] | undefined): string | undefined => {
const submitActions: EmbeddedFlowComponent[] = flattenComponents(comps).filter(
(comp: EmbeddedFlowComponent) => comp.id && comp.eventType?.toUpperCase() === 'SUBMIT',
);
const denyAction: EmbeddedFlowComponent | undefined = submitActions.find(
(comp: EmbeddedFlowComponent) => comp.variant?.toLowerCase() !== 'primary',
);

return (denyAction ?? submitActions[0])?.id;
};

/**
* A component-driven SignIn component that provides authentication flow with pre-built styling.
* This component handles the flow API calls for authentication and delegates UI logic to BaseSignIn.
Expand Down Expand Up @@ -249,6 +276,16 @@ const SignIn: FC<SignInProps> = ({
const initializationAttemptedRef: any = useRef(false);
const oauthCodeProcessedRef: any = useRef(false);
const passkeyProcessedRef: any = useRef(false);
// Deadline this component has already auto-submitted for, so a re-run of the timeout effect
// cannot submit the same expired step twice.
const timeoutSubmittedForRef = useRef<number | null>(null);
// The timeout effect is keyed on the deadline alone, so a step that arrives carrying the same
// deadline would otherwise leave the pending timer holding the previous step's context.
const timeoutContextRef = useRef<{
components: EmbeddedFlowComponent[];
hasConsentPrompt: boolean;
submit: (payload: EmbeddedSignInFlowRequest) => Promise<void>;
} | null>(null);
/**
* Sets executionId between sessionStorage and state.
* This ensures both are always in sync.
Expand Down Expand Up @@ -674,36 +711,6 @@ const SignIn: FC<SignInProps> = ({
}
}, [isInitialized, isStorageReady, isLoading, isFlowInitialized, currentExecutionId]);

/**
* Handle step timeout if configured in additionalData.
*/
useEffect(() => {
const timeoutMs: number = Number(additionalData?.['stepTimeout']) || 0;
if (timeoutMs <= 0 || !isFlowInitialized) {
setIsTimeoutDisabled(false);
return undefined;
}

const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000));

const handleTimeout = (): void => {
const errorMessage: string = t('errors.signin.timeout') || 'Time allowed to complete the step has expired.';
setError(new Error(errorMessage));
setIsTimeoutDisabled(true);
};

if (remaining <= 0) {
handleTimeout();
return undefined;
}

const timerId: any = setTimeout(() => {
handleTimeout();
}, remaining * 1000);

return () => clearTimeout(timerId);
}, [additionalData?.['stepTimeout'], isFlowInitialized, t]);

/**
* Handle form submission from BaseSignIn or render props.
*/
Expand All @@ -717,6 +724,10 @@ const SignIn: FC<SignInProps> = ({

const processedInputs: Record<string, any> = {...payload.inputs};

// Read and strip the sentinel up front so it can never reach the wire as an input
const consentReason: string | undefined = processedInputs[CONSENT_REASON_KEY];
delete processedInputs[CONSENT_REASON_KEY];

// Auto-compile consent decisions if we are currently on a consent prompt step
if (additionalData?.['consentPrompt']) {
try {
Expand Down Expand Up @@ -750,7 +761,17 @@ const SignIn: FC<SignInProps> = ({
}
}

// An expired prompt is never an approval, whichever action carried the submission
if (consentReason === ConsentConstants.REASON_TIMEOUT) {
isDeny = true;
}

// An explicit denial carries a reason too, so the server never has to infer one
const reason: string | undefined = consentReason ?? (isDeny ? ConsentConstants.REASON_USER_DENIED : undefined);

const decisions: any = {
approved: !isDeny,
...(reason ? {reason} : {}),
purposes: purposes.map((p: any) => ({
approved: !isDeny,
elements: [
Expand Down Expand Up @@ -879,6 +900,56 @@ const SignIn: FC<SignInProps> = ({
}
};

/**
* Handle step timeout if configured in additionalData.
*
* An expired consent prompt is auto-submitted as a denial carrying the timeout reason, because its
* actions no longer lead anywhere and the user would otherwise be stranded on the prompt. The
* server discards those decisions and records nothing. Every other kind of step surfaces the
* expiry error instead.
*/
timeoutContextRef.current = {
components,
hasConsentPrompt: Boolean(additionalData?.['consentPrompt']),
submit: handleSubmit,
};

useEffect(() => {
const timeoutMs: number = Number(additionalData?.['stepTimeout']) || 0;
if (timeoutMs <= 0 || !isFlowInitialized) {
setIsTimeoutDisabled(false);
return undefined;
}

const handleTimeout = (): void => {
setIsTimeoutDisabled(true);

const expiredError = (): Error =>
new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.');

// Read through the ref so the timer always sees the step that is on screen when it fires
const context = timeoutContextRef.current;
const actionId: string | undefined = context?.hasConsentPrompt
? findConsentSubmitActionId(context.components)
: undefined;

// Without an action the prompt node cannot be routed, so there is nothing to submit
if (actionId && context && timeoutSubmittedForRef.current !== timeoutMs) {
timeoutSubmittedForRef.current = timeoutMs;
context
.submit({action: actionId, inputs: {[CONSENT_REASON_KEY]: ConsentConstants.REASON_TIMEOUT}})
.catch(() => setError(expiredError()));
return;
}

setError(expiredError());
};

const timerId: ReturnType<typeof setTimeout> = setTimeout(handleTimeout, Math.max(0, timeoutMs - Date.now()));

return () => clearTimeout(timerId);
}, [additionalData?.['stepTimeout'], isFlowInitialized, t]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Handle authentication errors.
*/
Expand Down
Loading
Loading