-
Notifications
You must be signed in to change notification settings - Fork 2
feat: prefer canonical awaiting-input contract #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bc9b0a0
4aa184f
30a2d0b
a59c8d1
aa4d14b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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, | ||
|
|
@@ -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, | ||
| })); | ||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Legacy fallback ignores canonical presenceMedium Severity
Reviewed by Cursor Bugbot for commit aa4d14b. Configure here. |
||
| }; | ||
| } | ||
|
|
||
| export interface ManagedAuthSessionOptions extends ApiClientOptions { | ||
| sessionId: string; | ||
| handoffCode: string; | ||
|
|
@@ -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); | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
|
|
@@ -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", | ||
| ); | ||
| }, | ||
|
|
@@ -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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MFA ignores canonical choice submitMedium Severity MFA options built from canonical Additional Locations (1)Reviewed by Cursor Bugbot for commit 30a2d0b. Configure here. |
||
| "Failed to initiate SSO login", | ||
| ); | ||
| }, | ||
|
|
||


There was a problem hiding this comment.
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
mfaOptionsFromCanonicalonly copiesdescriptionfrom each MFA choice, whilesignInOptionsFromCanonicalalso falls back tocontextanddisplay_text. Masked phone numbers or similar MFA hints often live in those fields, so canonical MFA steps can show labels without the extra detail legacytargetused to provide.Reviewed by Cursor Bugbot for commit aa4d14b. Configure here.