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.
28 changes: 24 additions & 4 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,6 +130,24 @@ 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,
Expand All @@ -137,7 +157,7 @@ export function submitSSOButton(
return submit(
id,
jwt,
{ fields: {}, sso_button_selector: selector },
{ sso_button_selector: selector },
options,
);
}
Expand All @@ -148,7 +168,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 @@ -160,7 +180,7 @@ export function submitSignInOption(
return submit(
id,
jwt,
{ fields: {}, sign_in_option_id: signInOptionId },
{ sign_in_option_id: signInOptionId },
options,
);
}
Expand Down
35 changes: 35 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,42 @@ 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 +115,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
97 changes: 92 additions & 5 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,6 +64,8 @@ function mergeStateEvent(
flow_status: ev.flow_status,
flow_step: ev.flow_step,
flow_type: ev.flow_type ?? base.flow_type ?? null,
fields: ev.fields ?? null,
choices: ev.choices ?? 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.

SSE drops stored canonical fields

High Severity

The mergeStateEvent function incorrectly sets fields and choices to null when an incoming SSE managed_auth_state event omits these properties. This differs from other fields that correctly fall back to prior state. This behavior causes normalizeManagedAuthState to lose essential data, leading to UI elements like login forms or other awaiting-input components being unexpectedly wiped or disappearing after a partial state update.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bc9b0a0. Configure here.

discovered_fields: ev.discovered_fields ?? null,
pending_sso_buttons: ev.pending_sso_buttons ?? null,
mfa_options: ev.mfa_options ?? null,
Expand All @@ -71,6 +80,77 @@ 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) 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) return null;
return choices
.filter((choice) => choice.type === "sso_provider")
.map((choice) => ({
id: choice.id,
provider: choice.id,
selector: choice.observed_selector || choice.id,
label: choice.label,
}));
}

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

function signInOptionsFromCanonical(choices?: ManagedAuthChoice[] | null): SignInOption[] | null {
if (!choices) return null;
return 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,
}));
}

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty canonical slices hide legacy

Medium Severity

The normalizeManagedAuthState function's use of ?? for canonical field preference doesn't account for *FromCanonical helpers returning empty arrays ([]). This prevents the intended fallback to populated legacy fields (like discovered_fields, pending_sso_buttons, mfa_options, sign_in_options), causing their values to be dropped instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bc9b0a0. Configure here.

};
}

export interface ManagedAuthSessionOptions extends ApiClientOptions {
sessionId: string;
handoffCode: string;
Expand Down Expand Up @@ -167,7 +247,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 +294,7 @@ 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 +418,7 @@ 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 +490,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 +506,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),
"Failed to initiate SSO login",
);
},
Expand Down
Loading