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
157 changes: 152 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,13 @@ function mergeStateEvent(
flow_status: ev.flow_status,
flow_step: ev.flow_step,
flow_type: ev.flow_type ?? base.flow_type ?? null,
// State events carry a complete snapshot of the awaiting-input state:
// the server omits fields/choices when the current step has none, so an
// absent key means "cleared", not "unchanged". Falling back to the prior
// state here leaks a previous step's inputs (e.g. login SSO buttons
// rendering on the MFA step).
fields: ev.fields ?? null,
choices: ev.choices ?? null,
discovered_fields: ev.discovered_fields ?? null,
pending_sso_buttons: ev.pending_sso_buttons ?? null,
mfa_options: ev.mfa_options ?? null,
Expand All @@ -71,6 +85,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,

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 canonical mapping drops display text

Medium Severity

mfaOptionsFromCanonical only copies description from each MFA choice, while signInOptionsFromCanonical also falls back to context and display_text. Masked phone numbers or similar MFA hints often live in those fields, so canonical MFA steps can show labels without the extra detail legacy target used to provide.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aa4d14b. Configure here.

}));
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Legacy fallback ignores canonical presence

Medium Severity

normalizeManagedAuthState treats a failed or empty canonical conversion as “no canonical data” and falls back to legacy discovered_fields, pending_sso_buttons, mfa_options, and sign_in_options. When the response includes a non-null fields or choices key (including [] or choices with only some types), legacy arrays on the same payload can still render, so a step can show inputs the canonical contract meant to omit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aa4d14b. Configure here.

};
}

export interface ManagedAuthSessionOptions extends ApiClientOptions {
sessionId: string;
handoffCode: string;
Expand Down Expand Up @@ -167,7 +303,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 +350,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 +476,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 +550,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 +566,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