diff --git a/.changeset/canonical-awaiting-input.md b/.changeset/canonical-awaiting-input.md new file mode 100644 index 0000000..130a0a3 --- /dev/null +++ b/.changeset/canonical-awaiting-input.md @@ -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. diff --git a/packages/managed-auth-react/src/lib/api.ts b/packages/managed-auth-react/src/lib/api.ts index 1d995d1..105cba5 100644 --- a/packages/managed-auth-react/src/lib/api.ts +++ b/packages/managed-auth-react/src/lib/api.ts @@ -92,7 +92,9 @@ export async function retrieveManagedAuth( } interface SubmitBody { - fields: Record; + fields?: Record; + field_values?: Record; + selected_choice_id?: string; sso_button_selector?: string; mfa_option_id?: MFAType; sign_in_option_id?: string; @@ -128,18 +130,31 @@ export function submitFieldValues( return submit(id, jwt, { fields }, options); } +export function submitCanonicalFieldValues( + id: string, + jwt: string, + fieldValues: Record, + options?: ApiClientOptions, +): Promise { + return submit(id, jwt, { field_values: fieldValues }, options); +} + +export function submitSelectedChoice( + id: string, + jwt: string, + selectedChoiceId: string, + options?: ApiClientOptions, +): Promise { + return submit(id, jwt, { selected_choice_id: selectedChoiceId }, options); +} + export function submitSSOButton( id: string, jwt: string, selector: string, options?: ApiClientOptions, ): Promise { - return submit( - id, - jwt, - { fields: {}, sso_button_selector: selector }, - options, - ); + return submit(id, jwt, { sso_button_selector: selector }, options); } export function submitMFASelection( @@ -148,7 +163,7 @@ export function submitMFASelection( mfaType: MFAType, options?: ApiClientOptions, ): Promise { - return submit(id, jwt, { fields: {}, mfa_option_id: mfaType }, options); + return submit(id, jwt, { mfa_option_id: mfaType }, options); } export function submitSignInOption( @@ -157,12 +172,7 @@ export function submitSignInOption( signInOptionId: string, options?: ApiClientOptions, ): Promise { - 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. */ diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index 70c851e..85f13b3 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -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"; @@ -38,6 +40,7 @@ export interface DiscoveredField { } export interface SSOButton { + id?: string; provider: string; selector: string; label?: string; @@ -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[]; @@ -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; diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index 1e817c7..ea12c7e 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -5,7 +5,9 @@ import { retrieveManagedAuth, streamManagedAuthEvents, submitFieldValues, + submitCanonicalFieldValues, submitMFASelection, + submitSelectedChoice, submitSignInOption, submitSSOButton, type ApiClientOptions, @@ -14,8 +16,13 @@ import { import type { AuthErrorPayload, AuthSuccessPayload, + DiscoveredField, + ManagedAuthChoice, + ManagedAuthField, ManagedAuthResponse, MFAType, + MFAOption, + SignInOption, SSOButton, UIState, } from "../lib/types"; @@ -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, external_action_message: ev.external_action_message ?? null, website_error: ev.website_error ?? null, error_message: ev.error_message ?? null, @@ -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; @@ -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); @@ -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; @@ -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); @@ -410,8 +546,12 @@ export function useManagedAuthSession( const submitFields = useCallback( async (credentials: Record) => { 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", ); }, @@ -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), "Failed to initiate SSO login", ); },