Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/canonical-awaiting-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@onkernel/managed-auth-react": patch
---

Prefer the canonical managed-auth awaiting-input contract (`fields` and `choices`) when present, while continuing to fall back to legacy `discovered_fields`, `pending_sso_buttons`, `mfa_options`, and `sign_in_options` during the deprecation window.
38 changes: 24 additions & 14 deletions packages/managed-auth-react/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export async function retrieveManagedAuth(
}

interface SubmitBody {
fields: Record<string, string>;
fields?: Record<string, string>;
field_values?: Record<string, string>;
selected_choice_id?: string;
sso_button_selector?: string;
mfa_option_id?: MFAType;
sign_in_option_id?: string;
Expand Down Expand Up @@ -128,18 +130,31 @@ export function submitFieldValues(
return submit(id, jwt, { fields }, options);
}

export function submitCanonicalFieldValues(
id: string,
jwt: string,
fieldValues: Record<string, string>,
options?: ApiClientOptions,
): Promise<void> {
return submit(id, jwt, { field_values: fieldValues }, options);
}

export function submitSelectedChoice(
id: string,
jwt: string,
selectedChoiceId: string,
options?: ApiClientOptions,
): Promise<void> {
return submit(id, jwt, { selected_choice_id: selectedChoiceId }, options);
}

export function submitSSOButton(
id: string,
jwt: string,
selector: string,
options?: ApiClientOptions,
): Promise<void> {
return submit(
id,
jwt,
{ fields: {}, sso_button_selector: selector },
options,
);
return submit(id, jwt, { sso_button_selector: selector }, options);
}

export function submitMFASelection(
Expand All @@ -148,7 +163,7 @@ export function submitMFASelection(
mfaType: MFAType,
options?: ApiClientOptions,
): Promise<void> {
return submit(id, jwt, { fields: {}, mfa_option_id: mfaType }, options);
return submit(id, jwt, { mfa_option_id: mfaType }, options);
}

export function submitSignInOption(
Expand All @@ -157,12 +172,7 @@ export function submitSignInOption(
signInOptionId: string,
options?: ApiClientOptions,
): Promise<void> {
return submit(
id,
jwt,
{ fields: {}, sign_in_option_id: signInOptionId },
options,
);
return submit(id, jwt, { sign_in_option_id: signInOptionId }, options);
}

/** Callbacks for the SSE event stream. */
Expand Down
41 changes: 41 additions & 0 deletions packages/managed-auth-react/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type MFAType =
| "other";

export interface DiscoveredField {
id?: string;
ref?: string;
name: string;
label: string;
type: "text" | "email" | "password" | "tel" | "code" | "totp";
Expand All @@ -38,6 +40,7 @@ export interface DiscoveredField {
}

export interface SSOButton {
id?: string;
provider: string;
selector: string;
label?: string;
Expand All @@ -56,12 +59,48 @@ export interface SignInOption {
description?: string | null;
}

export interface ManagedAuthField {
id: string;
ref: string;
type:
| "identifier"
| "password"
| "code"
| "totp_code"
| "totp_secret"
| "text";
label?: string;
required?: boolean;
observed_selector?: string | null;
}

export type ManagedAuthChoiceType =
| "mfa_method"
| "sso_provider"
| "sign_in_method"
| "auth_method"
| "identifier_method"
| "account"
| "other";

export interface ManagedAuthChoice {
id: string;
type: ManagedAuthChoiceType;
label: string;
description?: string | null;
observed_selector?: string | null;
display_text?: string | null;
context?: string | null;
}

export interface ManagedAuthStateEventData {
event: "managed_auth_state";
timestamp: string;
flow_status: FlowStatus;
flow_step: FlowStep;
flow_type?: "LOGIN" | "REAUTH";
fields?: ManagedAuthField[];
choices?: ManagedAuthChoice[];
discovered_fields?: DiscoveredField[];
mfa_options?: MFAOption[];
sign_in_options?: SignInOption[];
Expand All @@ -82,6 +121,8 @@ export interface ManagedAuthResponse {
flow_status: FlowStatus;
flow_step: FlowStep;
flow_type?: "LOGIN" | "REAUTH" | null;
fields?: ManagedAuthField[] | null;
choices?: ManagedAuthChoice[] | null;
discovered_fields?: DiscoveredField[] | null;
pending_sso_buttons?: SSOButton[] | null;
mfa_options?: MFAOption[] | null;
Expand Down
161 changes: 152 additions & 9 deletions packages/managed-auth-react/src/session/useManagedAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
retrieveManagedAuth,
streamManagedAuthEvents,
submitFieldValues,
submitCanonicalFieldValues,
submitMFASelection,
submitSelectedChoice,
submitSignInOption,
submitSSOButton,
type ApiClientOptions,
Expand All @@ -14,8 +16,13 @@ import {
import type {
AuthErrorPayload,
AuthSuccessPayload,
DiscoveredField,
ManagedAuthChoice,
ManagedAuthField,
ManagedAuthResponse,
MFAType,
MFAOption,
SignInOption,
SSOButton,
UIState,
} from "../lib/types";
Expand Down Expand Up @@ -57,10 +64,13 @@ function mergeStateEvent(
flow_status: ev.flow_status,
flow_step: ev.flow_step,
flow_type: ev.flow_type ?? base.flow_type ?? null,
discovered_fields: ev.discovered_fields ?? null,
pending_sso_buttons: ev.pending_sso_buttons ?? null,
mfa_options: ev.mfa_options ?? null,
sign_in_options: ev.sign_in_options ?? null,
fields: ev.fields ?? base.fields ?? null,
choices: ev.choices ?? base.choices ?? null,
discovered_fields: ev.discovered_fields ?? base.discovered_fields ?? null,
pending_sso_buttons:
ev.pending_sso_buttons ?? base.pending_sso_buttons ?? null,
mfa_options: ev.mfa_options ?? base.mfa_options ?? null,
sign_in_options: ev.sign_in_options ?? base.sign_in_options ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale inputs on partial SSE

High Severity

The mergeStateEvent and retrieveManagedAuth functions now retain previous fields, choices, and legacy awaiting-input arrays when an incoming state event or API response omits them or sends null. This can leave outdated forms, MFA, SSO, or sign-in options visible and clickable, even if the server intended to clear them, potentially leading to stale UI and incorrect submissions.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30a2d0b. Configure here.

Comment on lines +67 to +73

@vercel vercel Bot Jun 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mergeStateEvent's ?? base.* fallback on the canonical/legacy input arrays leaks a previous AWAITING_INPUT step's content (e.g. login SSO buttons rendering on the MFA step) because canonical snapshots express "category no longer applies" by omission.

Fix on Vercel

external_action_message: ev.external_action_message ?? null,
website_error: ev.website_error ?? null,
error_message: ev.error_message ?? null,
Expand All @@ -71,6 +81,128 @@ function mergeStateEvent(
};
}

function fieldTypeToDiscoveredType(
field: ManagedAuthField,
): DiscoveredField["type"] {
switch (field.type) {
case "identifier":
return "email";
case "totp_code":
return "totp";
case "totp_secret":
return "text";
default:
return field.type;
}
}

function fieldsFromCanonical(
fields?: ManagedAuthField[] | null,
): DiscoveredField[] | null {
if (!fields?.length) return null;
return fields.map((field) => ({
id: field.id,
ref: field.ref,
name: field.id,
type: fieldTypeToDiscoveredType(field),
label: field.label || field.ref,
required: field.required ?? true,
}));
}

function ssoButtonsFromCanonical(
choices?: ManagedAuthChoice[] | null,
): SSOButton[] | null {
if (!choices?.length) return null;
const buttons = choices
.filter((choice) => choice.type === "sso_provider")
.map((choice) => ({
id: choice.id,
provider: choice.id,
selector: choice.observed_selector || choice.id,
label: choice.label,
}));
return buttons.length ? buttons : null;
}

function normalizeMFAChoiceId(id: string): MFAType {
switch (id.trim().toLowerCase()) {
case "sms_code":
case "sms":
return "sms";
case "email_code":
case "email":
return "email";
case "totp_code":
case "totp":
case "authenticator":
case "authenticator_app":
return "totp";
case "phone_call":
case "call":
return "call";
case "push":
return "push";
case "password":
return "password";
case "passkey":
return "passkey";
case "switch":
return "switch";
default:
return "other";
}
}

function mfaOptionsFromCanonical(
choices?: ManagedAuthChoice[] | null,
): MFAOption[] | null {
if (!choices?.length) return null;
const options = choices
.filter((choice) => choice.type === "mfa_method")
.map((choice) => ({
type: normalizeMFAChoiceId(choice.id),
label: choice.label,
description: choice.description ?? undefined,
}));
return options.length ? options : null;
}

function signInOptionsFromCanonical(
choices?: ManagedAuthChoice[] | null,
): SignInOption[] | null {
if (!choices?.length) return null;
const options = choices
.filter(
(choice) =>
choice.type !== "sso_provider" && choice.type !== "mfa_method",
)
.map((choice) => ({
id: choice.id,
label: choice.label,
description:
choice.description ?? choice.context ?? choice.display_text ?? null,
}));
return options.length ? options : null;
}

function normalizeManagedAuthState(
state: ManagedAuthResponse,
): ManagedAuthResponse {
return {
...state,
// Prefer the canonical contract when present; legacy fields stay as fallback
// during the deprecation period.
discovered_fields:
fieldsFromCanonical(state.fields) ?? state.discovered_fields,
pending_sso_buttons:
ssoButtonsFromCanonical(state.choices) ?? state.pending_sso_buttons,
mfa_options: mfaOptionsFromCanonical(state.choices) ?? state.mfa_options,
sign_in_options:
signInOptionsFromCanonical(state.choices) ?? state.sign_in_options,
};
}

export interface ManagedAuthSessionOptions extends ApiClientOptions {
sessionId: string;
handoffCode: string;
Expand Down Expand Up @@ -167,7 +299,7 @@ export function useManagedAuthSession(
setSubmitError(null);
const base = stateRef.current;
if (!base) return;
const merged = mergeStateEvent(base, ev);
const merged = normalizeManagedAuthState(mergeStateEvent(base, ev));
stateRef.current = merged;
setState(merged);
const nextUI = deriveUIState(merged);
Expand Down Expand Up @@ -214,7 +346,9 @@ export function useManagedAuthSession(
const gen = generationRef.current;
if (terminalRef.current) return;
try {
const fresh = await retrieveManagedAuth(sessionId, t, options);
const fresh = normalizeManagedAuthState(
await retrieveManagedAuth(sessionId, t, options),
);
if (gen !== generationRef.current) return;
if (terminalRef.current) return;
stateRef.current = fresh;
Expand Down Expand Up @@ -338,7 +472,9 @@ export function useManagedAuthSession(
);
if (exchangeRef.current !== ref || !ref.active) return;
setJwt(token);
const initial = await retrieveManagedAuth(sessionId, token, options);
const initial = normalizeManagedAuthState(
await retrieveManagedAuth(sessionId, token, options),
);
if (exchangeRef.current !== ref || !ref.active) return;
stateRef.current = initial;
setState(initial);
Expand Down Expand Up @@ -410,8 +546,12 @@ export function useManagedAuthSession(
const submitFields = useCallback(
async (credentials: Record<string, string>) => {
if (!jwt) return;
const hasCanonicalFields = (stateRef.current?.fields?.length ?? 0) > 0;
return submit(
() => submitFieldValues(sessionId, jwt, credentials, options),
() =>
hasCanonicalFields
? submitCanonicalFieldValues(sessionId, jwt, credentials, options)
: submitFieldValues(sessionId, jwt, credentials, options),
"Failed to submit credentials",
);
},
Expand All @@ -422,7 +562,10 @@ export function useManagedAuthSession(
async (button: SSOButton) => {
if (!jwt) return;
return submit(
() => submitSSOButton(sessionId, jwt, button.selector, options),
() =>
button.id
? submitSelectedChoice(sessionId, jwt, button.id, options)
: submitSSOButton(sessionId, jwt, button.selector, options),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MFA ignores canonical choice submit

Medium Severity

MFA options built from canonical mfa_method choices still submit via mfa_option_id, while SSO clicks with button.id use selected_choice_id. If the server expects canonical choice ids on submit, MFA selection can fail even when the UI was rendered from choices.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30a2d0b. Configure here.

"Failed to initiate SSO login",
);
},
Expand Down
Loading