From be811ac074f398e6440709df1c9e4cc75c70b8dc Mon Sep 17 00:00:00 2001 From: ikovic Date: Wed, 9 Sep 2026 19:03:14 +0200 Subject: [PATCH 1/4] Attempt SSO using the new hook method --- .changeset/one-step-sso-hook.md | 5 + packages/react/README.md | 12 + packages/react/package.json | 2 +- .../dynamic-flow/dynamic-flow.test.tsx | 246 +++++++++++++++++- .../components/dynamic-flow/handle-form.tsx | 1 + .../src/components/dynamic-flow/index.tsx | 68 ++++- .../src/components/dynamic-flow/initial.tsx | 64 +++-- .../src/components/form/error/error.test.tsx | 42 +++ .../react/src/components/form/error/index.tsx | 10 + .../src/components/form/initial/controls.tsx | 1 + .../react/src/components/text/constants.ts | 4 + .../src/context/slash-id-context.test.tsx | 31 +++ .../react/src/context/slash-id-context.tsx | 6 + packages/react/src/context/test-providers.tsx | 3 + packages/react/src/domain/handles.test.ts | 32 +++ packages/react/src/domain/handles.ts | 18 ++ packages/react/src/domain/types.ts | 2 + packages/react/src/hooks/use-last-factor.ts | 9 + pnpm-lock.yaml | 45 +++- 19 files changed, 578 insertions(+), 23 deletions(-) create mode 100644 .changeset/one-step-sso-hook.md diff --git a/.changeset/one-step-sso-hook.md b/.changeset/one-step-sso-hook.md new file mode 100644 index 00000000..3e40f3e7 --- /dev/null +++ b/.changeset/one-step-sso-hook.md @@ -0,0 +1,5 @@ +--- +"@slashid/react": minor +--- + +`DynamicFlow` gains an `attemptSSO` prop. With it on, the `hook` factor is submitted for email identifiers before `getFactors` is consulted, so the organization's `identify_user` webhook can pick the factor (one-step SSO). When the API resolves nothing, the flow continues with `getFactors` for the same identifier. Requires `@slashid/slashid` with `HookFactorUnresolvedError`. diff --git a/packages/react/README.md b/packages/react/README.md index bb90129e..0215c654 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -73,3 +73,15 @@ function App() { ``` Once the `logIn` function resolves, your component will render again with the newly logged-in `user` object. + +### DynamicFlow + +`DynamicFlow` asks for an identifier first and then picks the factors to offer from the `getFactors` callback. + +#### One-step SSO (`attemptSSO`) + +```tsx + [{ method: "email_link" }, { method: "password" }]} /> +``` + +With `attemptSSO`, `DynamicFlow` submits the `hook` factor right after the identifier step for email identifiers. The organization's `identify_user` webhook picks the factor (for example a SAML or OIDC provider), and the flow continues with it. When nothing is resolved, `getFactors` is called with the same identifier as usual: a single factor is submitted directly, otherwise the picker is shown. Other identifier types never attempt SSO. diff --git a/packages/react/package.json b/packages/react/package.json index f46b75b1..f4f5c7fc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.0.2", - "@slashid/slashid": "3.29.6", + "@slashid/slashid": "3.30.0-hook-beta.1", "@storybook/addon-essentials": "7.6.19", "@storybook/addon-interactions": "7.4.0", "@storybook/addon-links": "7.4.0", diff --git a/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx b/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx index 5f525cea..7e5a58cd 100644 --- a/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx +++ b/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx @@ -1,4 +1,4 @@ -import { Factor, PersonHandle } from "@slashid/slashid"; +import { Errors, Factor, PersonHandle, User } from "@slashid/slashid"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi, describe } from "vitest"; @@ -286,4 +286,248 @@ describe("#DynamicFlow", () => { ).toBeInTheDocument(); expect(onSuccess).toHaveBeenCalledWith(testUser); }); + + test("attempts SSO with the hook factor before resolving factors", async () => { + const logInMock = vi.fn(() => new Promise(() => {})); + const getFactors = vi.fn(() => [{ method: "email_link" }] as Factor[]); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-authenticating-state") + ).resolves.toBeInTheDocument(); + expect(getFactors).not.toHaveBeenCalled(); + expect(logInMock).toHaveBeenCalledTimes(1); + expect(logInMock).toHaveBeenCalledWith( + { + factor: { method: "hook" }, + handle: { type: "email_address", value: "user@acme.test" }, + }, + { middleware: undefined } + ); + }); + + const hookUnresolved = () => + Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.hookFactorUnresolved, + message: "unresolved", + }); + + test("resolves factors with the same handle when the SSO attempt is unresolved", async () => { + const logInMock = vi.fn( + (): Promise => Promise.reject(hookUnresolved()) + ); + const getFactors = vi.fn( + () => [{ method: "email_link" }, { method: "password" }] as Factor[] + ); + const onError = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-dynamic-flow--resolved-factors") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(1); + expect(getFactors).toHaveBeenCalledWith({ + type: "email_address", + value: "user@acme.test", + }); + expect(onError).not.toHaveBeenCalled(); + expect(screen.queryByTestId("sid-form-error-state")).not.toBeInTheDocument(); + + // the picker submits with the handle from the first step + logInMock.mockImplementation(() => Promise.resolve(createTestUser())); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + await expect( + screen.findByTestId("sid-form-success-state") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenLastCalledWith( + { + factor: { method: "email_link" }, + handle: { type: "email_address", value: "user@acme.test" }, + }, + { middleware: undefined } + ); + }); + + test("the picker's back button returns to the identifier step", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const user = userEvent.setup(); + + render( + + + [{ method: "email_link" }, { method: "password" }]} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + await screen.findByTestId("sid-dynamic-flow--resolved-factors"); + + await user.click(screen.getByTestId("sid-form-authenticating-cancel-button")); + expect( + screen.getByPlaceholderText(TEXT["initial.handle.email.placeholder"]) + ).toBeInTheDocument(); + }); + + test("submits a single resolved factor directly when the SSO attempt is unresolved", async () => { + const testUser = createTestUser(); + const logInMock = vi + .fn() + .mockImplementationOnce(() => Promise.reject(hookUnresolved())) + .mockImplementationOnce(() => Promise.resolve(testUser)); + const onSuccess = vi.fn(); + const user = userEvent.setup(); + + render( + + + [{ method: "email_link" }]} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-success-state") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(2); + expect(logInMock).toHaveBeenNthCalledWith( + 2, + { + factor: { method: "email_link" }, + handle: { type: "email_address", value: "user@acme.test" }, + }, + { middleware: undefined } + ); + expect(onSuccess).toHaveBeenCalledWith(testUser); + }); + + test("still reports other errors of an SSO attempt", async () => { + const logInMock = vi.fn(() => Promise.reject(new Error("idp down"))); + const onError = vi.fn(); + const user = userEvent.setup(); + + render( + + + [{ method: "email_link" }]} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-error-state") + ).resolves.toBeInTheDocument(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test("shows the resolved SSO factor and succeeds with the manager-org user", async () => { + const sid = new MockSlashID({ oid: "dashboard-oid" }); + const managerUser = createTestUser({ oid: "manager-oid" }); + const logInMock = vi.fn(async () => { + sid.mockPublish("authnContextUpdateChallengeReceivedEvent", { + targetOrgId: "dashboard-oid", + factor: { + method: "saml", + options: { method: "saml", provider_credentials_id: "creds" }, + }, + }); + return managerUser; + }); + const onSuccess = vi.fn(); + const user = userEvent.setup(); + + render( + + + [{ method: "email_link" }]} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-success-state") + ).resolves.toBeInTheDocument(); + expect(onSuccess).toHaveBeenCalledWith(managerUser); + }); + + test("resets to the identifier step when the resolved SSO login is refused", async () => { + const refused = Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.selfRegistrationNotAllowed, + message: "self-registration not allowed for this organization", + }); + const logInMock = vi.fn(() => Promise.reject(refused)); + const getFactors = vi.fn( + () => [{ method: "email_link" }, { method: "password" }] as Factor[] + ); + const onError = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await user.click(await screen.findByTestId("sid-form-error-retry-button")); + await expect( + screen.findByTestId("sid-form-initial-submit-button") + ).resolves.toBeInTheDocument(); + expect(getFactors).not.toHaveBeenCalled(); + expect( + screen.queryByTestId("sid-dynamic-flow--resolved-factors") + ).not.toBeInTheDocument(); + expect(onError).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react/src/components/dynamic-flow/handle-form.tsx b/packages/react/src/components/dynamic-flow/handle-form.tsx index 6e918a26..40c0d9b7 100644 --- a/packages/react/src/components/dynamic-flow/handle-form.tsx +++ b/packages/react/src/components/dynamic-flow/handle-form.tsx @@ -44,6 +44,7 @@ export const FACTOR_LABEL_MAP: Record< oidc: "", saml: "", totp: "", + hook: "", }; export type Props = { diff --git a/packages/react/src/components/dynamic-flow/index.tsx b/packages/react/src/components/dynamic-flow/index.tsx index 378d3aae..020573d0 100644 --- a/packages/react/src/components/dynamic-flow/index.tsx +++ b/packages/react/src/components/dynamic-flow/index.tsx @@ -1,13 +1,14 @@ -import { Factor } from "@slashid/slashid"; +import { Errors, Factor } from "@slashid/slashid"; import { clsx } from "clsx"; import { FormProvider } from "../../context/form-context"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Handle, LoginOptions } from "../../domain/types"; import { CreateFlowOptions } from "../form/flow/flow.common"; import { useFlowState } from "../form/useFlowState"; import { AuthenticatingImplementation as Authenticating } from "../form/authenticating"; import { Success } from "../form/success"; import { Error } from "../form/error"; +import { Loader } from "../form/authenticating/icons"; import * as styles from "./dynamic-flow.css"; import { Initial } from "./initial"; @@ -22,8 +23,16 @@ type Props = { onError?: CreateFlowOptions["onError"]; getFactors: (handle?: Handle) => Promise | Factor[]; middleware?: LoginOptions["middleware"]; + /** + * Submit the `hook` factor for email identifiers before calling `getFactors`, so the + * organization's identify_user webhook can pick the factor (one-step SSO). When the API + * resolves nothing the flow continues with `getFactors` for the same identifier. + */ + attemptSSO?: boolean; }; +type Resume = { handle: Handle; id: number }; + /** * This is a variant of the
component that allows you to dynamically change the factor based on the handle that was used. * The initial form will ask for a handle, and then the factor will be determined based on the handle that was entered. @@ -35,8 +44,29 @@ export const DynamicFlow = ({ onSuccess, onError, middleware, + attemptSSO, }: Props) => { - const flowState = useFlowState({ onSuccess, onError }); + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + const attemptedHandleRef = useRef(null); + const resumeCounter = useRef(0); + const [resume, setResume] = useState(null); + + // useFlowState creates the flow once, so this callback must stay stable and read through refs + const handleError = useCallback>( + (error, context) => { + if ( + attemptedHandleRef.current && + Errors.isHookFactorUnresolvedError(error) + ) { + return; + } + onErrorRef.current?.(error, context); + }, + [] + ); + + const flowState = useFlowState({ onSuccess, onError: handleError }); const { lastHandle } = useLastHandle(); const { lastFactor } = useLastFactor(); @@ -60,6 +90,30 @@ export const DynamicFlow = ({ [flowState, middleware] ); + const handleSSOAttempt = useCallback((handle: Handle) => { + attemptedHandleRef.current = handle; + }, []); + + const isPendingResume = + flowState.status === "error" && + attemptedHandleRef.current !== null && + Errors.isHookFactorUnresolvedError(flowState.context.error); + + useEffect(() => { + if (!isPendingResume) return; + + const handle = attemptedHandleRef.current!; + attemptedHandleRef.current = null; + resumeCounter.current += 1; + setResume({ handle, id: resumeCounter.current }); + flowState.cancel(); + }, [isPendingResume, flowState]); + + // the resumed instance is for one attempt; leaving the initial state discards it + useEffect(() => { + if (resume && flowState.status !== "initial") setResume(null); + }, [resume, flowState.status]); + return ( {flowState.status === "initial" && ( )} {flowState.status === "authenticating" && ( @@ -84,7 +143,8 @@ export const DynamicFlow = ({ )} - {flowState.status === "error" && } + {flowState.status === "error" && + (isPendingResume ? : )} {flowState.status === "success" && } diff --git a/packages/react/src/components/dynamic-flow/initial.tsx b/packages/react/src/components/dynamic-flow/initial.tsx index 4767d396..c15966cb 100644 --- a/packages/react/src/components/dynamic-flow/initial.tsx +++ b/packages/react/src/components/dynamic-flow/initial.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Factor } from "@slashid/slashid"; import { Divider } from "@slashid/react-primitives"; @@ -13,6 +13,7 @@ import { hasSSOAndNonSSOFactors, isFactorSSO, resolveLastHandleValue, + shouldAttemptSSO, } from "../../domain/handles"; import * as styles from "./dynamic-flow.css"; @@ -26,6 +27,10 @@ type Props = { handleSubmit: (factor: Factor, handle?: Handle) => void; getFactors: (handle: Handle) => Promise | Factor[]; middleware?: LoginOptions["middleware"]; + attemptSSO?: boolean; + /** Start at factor resolution for this handle; no SSO attempt is made for it. */ + initialHandle?: Handle; + onSSOAttempt?: (handle: Handle) => void; }; type PreAuthState = "idle" | "resolving_factors" | "resolved_factors"; @@ -35,28 +40,51 @@ export const Initial = ({ handleSubmit, middleware, getFactors, + attemptSSO, + initialHandle, + onSSOAttempt, }: Props) => { - const [handle, setHandle] = useState(); - const [preAuthState, setPreAuthState] = useState("idle"); + const [handle, setHandle] = useState(initialHandle); + const [preAuthState, setPreAuthState] = useState( + initialHandle ? "resolving_factors" : "idle" + ); const [factors, setFactors] = useState(); + const previousFlowState = useRef(flowState); useEffect(() => { (async () => { - if (handle && preAuthState === "resolving_factors") { - const f = await getFactors(handle); - if (f.length === 1) { - handleSubmit(f[0], handle); - return; - } + if (!handle || preAuthState !== "resolving_factors") return; + + if (shouldAttemptSSO(handle, attemptSSO, initialHandle)) { + onSSOAttempt?.(handle); + handleSubmit({ method: "hook" }, handle); + return; + } - setFactors(f); - setPreAuthState("resolved_factors"); + const f = await getFactors(handle); + if (f.length === 1) { + handleSubmit(f[0], handle); + return; } + + setFactors(f); + setPreAuthState("resolved_factors"); })(); - }, [getFactors, handle, handleSubmit, preAuthState]); + }, [ + attemptSSO, + getFactors, + handle, + handleSubmit, + initialHandle, + onSSOAttempt, + preAuthState, + ]); - // reset the form on back action (flow cancellation) + // reset on back action (flow cancellation); the mount run is skipped so a + // resumed instance is not bounced back to idle useEffect(() => { + if (previousFlowState.current === flowState) return; + previousFlowState.current = flowState; setPreAuthState("idle"); }, [flowState]); @@ -122,7 +150,10 @@ function Idle({ handleSubmit }: { handleSubmit: Props["handleSubmit"] }) { function ResolvingFactors() { return ( <> -
+
flowState.cancel()} /> -
+
Error state -> Special error cases", () => { ).toBeInTheDocument(); }); }); + +describe("#Form -> Error state -> hook factor unresolved", () => { + test("renders the hook unresolved copy and resets the flow on retry", async () => { + const logInMock = vi.fn(() => + Promise.reject( + Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.hookFactorUnresolved, + message: "unresolved", + }) + ) + ); + const user = userEvent.setup(); + const testTitle = "No sign-in method"; + + render( + + + + + + ); + + inputEmail("valid@email.com"); + + user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-error-state") + ).resolves.toBeInTheDocument(); + expect(screen.getByText(testTitle)).toBeInTheDocument(); + + user.click(screen.getByTestId("sid-form-error-retry-button")); + + await expect( + screen.findByTestId("sid-form-initial-state") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/components/form/error/index.tsx b/packages/react/src/components/form/error/index.tsx index 9d8aca73..967cb004 100644 --- a/packages/react/src/components/form/error/index.tsx +++ b/packages/react/src/components/form/error/index.tsx @@ -63,6 +63,7 @@ type ErrorType = | "selfRegistrationNotAllowed" | "signUpAwaitingApproval" | "signInAwaitingApproval" + | "hookFactorUnresolved" | "invalidEmailAddressFormat" | "invalidPhoneNumberFormat" | "unknown"; @@ -88,6 +89,8 @@ async function getErrorType(error: Error): Promise { if (Errors.isInvalidPhoneNumberFormatError(error)) return "invalidPhoneNumberFormat"; + if (Errors.isHookFactorUnresolvedError(error)) return "hookFactorUnresolved"; + if (Errors.isAPIResponseError(error)) return "response"; if (Errors.isRateLimitError(error)) { @@ -189,6 +192,12 @@ function mapErrorTypeToText(errorType: ErrorType): TextOverrides { description: "error.subtitle.signInAwaitingApproval", retry: "error.retry.signInAwaitingApproval", }; + case "hookFactorUnresolved": + return { + title: "error.title.hookFactorUnresolved", + description: "error.subtitle.hookFactorUnresolved", + retry: "error.retry.hookFactorUnresolved", + }; case "invalidEmailAddressFormat": return { title: "error.title.invalidEmailAddressFormat", @@ -217,6 +226,7 @@ function mapErrorTypeToRetryPolicy(errorType: ErrorType): RetryPolicy { case "selfRegistrationNotAllowed": case "signUpAwaitingApproval": case "signInAwaitingApproval": + case "hookFactorUnresolved": case "invalidEmailAddressFormat": case "invalidPhoneNumberFormat": return "reset"; diff --git a/packages/react/src/components/form/initial/controls.tsx b/packages/react/src/components/form/initial/controls.tsx index 635cd434..c16aed98 100644 --- a/packages/react/src/components/form/initial/controls.tsx +++ b/packages/react/src/components/form/initial/controls.tsx @@ -45,6 +45,7 @@ export const FACTOR_LABEL_MAP: Record< oidc: "", saml: "", totp: "", + hook: "", }; export const TAB_NAME = { diff --git a/packages/react/src/components/text/constants.ts b/packages/react/src/components/text/constants.ts index bb846a5e..697fb87d 100644 --- a/packages/react/src/components/text/constants.ts +++ b/packages/react/src/components/text/constants.ts @@ -166,6 +166,10 @@ export const TEXT = { "error.retry.selfRegistrationNotAllowed": "Go back to login", "error.retry.signUpAwaitingApproval": "Go back to login", "error.retry.signInAwaitingApproval": "Go back to login", + "error.title.hookFactorUnresolved": "No sign-in method available", + "error.subtitle.hookFactorUnresolved": + "We could not find a sign-in method for this account. Please try another way to sign in.", + "error.retry.hookFactorUnresolved": "Go back to login", "error.contactSupport.prompt": "Need help?", "error.contactSupport.cta": "Contact support", "error.divider": "or", diff --git a/packages/react/src/context/slash-id-context.test.tsx b/packages/react/src/context/slash-id-context.test.tsx index ce7a225f..aee39d0a 100644 --- a/packages/react/src/context/slash-id-context.test.tsx +++ b/packages/react/src/context/slash-id-context.test.tsx @@ -2,6 +2,7 @@ import { render, waitFor, screen } from "@testing-library/react"; import { SlashIDProviderImplementation } from "./slash-id-context"; import { MockSlashID } from "../components/test-utils"; import type { SlashIDOptions } from "@slashid/slashid"; +import { useSlashID } from "../hooks/use-slash-id"; describe("Lifecycle methods", () => { it("calls onInitError if getUserFromURL throws", async () => { @@ -62,3 +63,33 @@ describe("Lifecycle methods", () => { expect(screen.getByText("Test")).toBeInTheDocument(); }); }); + +function ShowOid() { + const { sdkState, __oid } = useSlashID(); + return {sdkState === "ready" ? __oid : ""}; +} + +describe("__oid", () => { + it("is the org the provider was booted on", async () => { + const createSlashID = (options: SlashIDOptions) => { + const mockSid = new MockSlashID(options); + mockSid.getUserFromURL = vi.fn().mockResolvedValue(null); + return mockSid; + }; + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("oid")).toHaveTextContent("boot-oid"); + }); + }); +}); diff --git a/packages/react/src/context/slash-id-context.tsx b/packages/react/src/context/slash-id-context.tsx index 8d0b1a4b..24771da6 100644 --- a/packages/react/src/context/slash-id-context.tsx +++ b/packages/react/src/context/slash-id-context.tsx @@ -134,6 +134,8 @@ export interface ISlashIDContext { }) => Promise; __syncExternalState: (state: ExternalStateParams) => Promise; __orgSwitchingState: OrgSwitchingState; + /** Internal. The org the provider currently authenticates and stores tokens for. */ + __oid?: string; } export const initialContextValue: ISlashIDContext = { @@ -151,6 +153,7 @@ export const initialContextValue: ISlashIDContext = { __switchOrganizationInContext: async () => undefined, __syncExternalState: async () => undefined, __orgSwitchingState: { state: "idle" }, + __oid: undefined, }; export const SlashIDContext = @@ -720,6 +723,7 @@ export function SlashIDProviderImplementation({ __switchOrganizationInContext, __syncExternalState, __orgSwitchingState: orgSwitchingState, + __oid: oid, }; } @@ -738,8 +742,10 @@ export function SlashIDProviderImplementation({ __switchOrganizationInContext, __syncExternalState, __orgSwitchingState: orgSwitchingState, + __oid: oid, }; }, [ + oid, state, user, anonymousUser, diff --git a/packages/react/src/context/test-providers.tsx b/packages/react/src/context/test-providers.tsx index 8c01d863..f3aa256b 100644 --- a/packages/react/src/context/test-providers.tsx +++ b/packages/react/src/context/test-providers.tsx @@ -30,6 +30,7 @@ export const TestSlashIDProvider: React.FC = ({ __switchOrganizationInContext = async () => undefined, __syncExternalState = async () => undefined, __orgSwitchingState = { state: "idle" }, + __oid, }) => { const [internalUser, setInternalUser] = React.useState(user); const eventBufferRef = React.useRef(null); @@ -88,6 +89,7 @@ export const TestSlashIDProvider: React.FC = ({ __switchOrganizationInContext, __syncExternalState, __orgSwitchingState, + __oid, }), [ sid, @@ -102,6 +104,7 @@ export const TestSlashIDProvider: React.FC = ({ __switchOrganizationInContext, __syncExternalState, __orgSwitchingState, + __oid, ] ); diff --git a/packages/react/src/domain/handles.test.ts b/packages/react/src/domain/handles.test.ts index 6389ea66..688afb03 100644 --- a/packages/react/src/domain/handles.test.ts +++ b/packages/react/src/domain/handles.test.ts @@ -1,8 +1,11 @@ import { + filterFactors, getHandleTypes, hasOidcAndNonOidcFactors, + isFactorHook, parsePhoneNumber, ParsedPhoneNumber, + shouldAttemptSSO, } from "./handles"; const phoneNumbersTestData: { @@ -111,3 +114,32 @@ describe("handles", () => { }); }); }); + +describe("hook factor", () => { + test("isFactorHook recognises the hook method only", () => { + expect(isFactorHook({ method: "hook" })).toBe(true); + expect(isFactorHook({ method: "email_link" })).toBe(false); + }); + + test("filterFactors never lists hook as a selectable method", () => { + expect( + filterFactors( + [{ method: "hook" }, { method: "email_link" }], + "email_address" + ) + ).toEqual([{ method: "email_link" }]); + }); + + test("shouldAttemptSSO only for email handles that were not resumed", () => { + const email = { type: "email_address" as const, value: "user@acme.test" }; + const phone = { type: "phone_number" as const, value: "+15550000000" }; + + expect(shouldAttemptSSO(email, true, undefined)).toBe(true); + expect(shouldAttemptSSO(email, false, undefined)).toBe(false); + expect(shouldAttemptSSO(email, undefined, undefined)).toBe(false); + expect(shouldAttemptSSO(phone, true, undefined)).toBe(false); + expect(shouldAttemptSSO(undefined, true, undefined)).toBe(false); + expect(shouldAttemptSSO(email, true, email)).toBe(false); + expect(shouldAttemptSSO(email, true, { ...email })).toBe(true); + }); +}); diff --git a/packages/react/src/domain/handles.ts b/packages/react/src/domain/handles.ts index 99fd585b..c5c9505a 100644 --- a/packages/react/src/domain/handles.ts +++ b/packages/react/src/domain/handles.ts @@ -5,6 +5,7 @@ import { } from "country-list-with-dial-code-and-flag"; import { FactorEmailLink, + FactorHook, FactorNonOIDC, FactorOIDC, FactorOTP, @@ -162,6 +163,23 @@ export function isFactorTOTP(factor: Factor): factor is FactorTOTP { return factor.method === "totp"; } +export function isFactorHook(factor: Factor): factor is FactorHook { + return factor.method === "hook"; +} + +export function shouldAttemptSSO( + handle: Handle | undefined, + attemptSSO: boolean | undefined, + resumedHandle: Handle | undefined +): handle is Handle { + return ( + !!attemptSSO && + !!handle && + handle.type === "email_address" && + handle !== resumedHandle + ); +} + export function hasOidcAndNonOidcFactors(factors: Factor[]): boolean { return factors.some(isFactorOidc) && factors.some((f) => !isFactorOidc(f)); } diff --git a/packages/react/src/domain/types.ts b/packages/react/src/domain/types.ts index f5a34468..46857cbe 100644 --- a/packages/react/src/domain/types.ts +++ b/packages/react/src/domain/types.ts @@ -76,6 +76,8 @@ export type FactorSmsLink = Extract; export type FactorTOTP = Extract; +export type FactorHook = Extract; + /** * Utility type to specify allowed handle types in case given factor supports more than one. */ diff --git a/packages/react/src/hooks/use-last-factor.ts b/packages/react/src/hooks/use-last-factor.ts index e0a84883..11d866b2 100644 --- a/packages/react/src/hooks/use-last-factor.ts +++ b/packages/react/src/hooks/use-last-factor.ts @@ -45,6 +45,15 @@ export const useLastFactor = (): UseLastFactorValue => { return; } + // hook is a placeholder the API replaces with the resolved factor; never remember it + if ( + authenticationFactor && + "method" in authenticationFactor && + authenticationFactor.method === "hook" + ) { + return; + } + try { window.localStorage.setItem( STORAGE_LAST_FACTOR_KEY(sid?.oid ?? ""), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b02b42..8b8837ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,8 +353,8 @@ importers: specifier: ^8.0.2 version: 8.3.1 '@slashid/slashid': - specifier: 3.29.6 - version: 3.29.6 + specifier: 3.30.0-hook-beta.1 + version: 3.30.0-hook-beta.1 '@storybook/addon-essentials': specifier: 7.6.19 version: 7.6.19(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) @@ -5084,6 +5084,13 @@ packages: resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} dev: true + /@jsdoc/salty@0.2.12: + resolution: {integrity: sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==} + engines: {node: '>=v12.0.0'} + dependencies: + lodash: 4.18.1 + dev: true + /@jspm/core@2.0.1: resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} dev: true @@ -8773,6 +8780,21 @@ packages: ua-parser-js: 1.0.37 url: 0.11.3 uuid: 8.3.2 + dev: false + + /@slashid/slashid@3.30.0-hook-beta.1: + resolution: {integrity: sha512-wy1cDvAb8XRUe00hOzQVverFSYtH01xzFRAtvLESBfaB4AESC9ApYqRrlX006i+qEM8/xzhNvIzwAdK3nDBzoQ==} + dependencies: + compare-versions: 6.1.0 + docdash: 2.0.2 + jwt-decode: 3.1.2 + qrcode: 1.5.3 + querystring-es3: 0.2.1 + regenerator-runtime: 0.14.1 + ua-parser-js: 1.0.37 + url: 0.11.3 + uuid: 11.1.1 + dev: true /@storybook/addon-actions@7.6.19: resolution: {integrity: sha512-ATLrA5QKFJt7tIAScRHz5T3eBQ+RG3jaZk08L7gChvyQZhei8knWwePElZ7GaWbCr9BgznQp1lQUUXq/UUblAQ==} @@ -13178,6 +13200,12 @@ packages: /docdash@1.2.0: resolution: {integrity: sha512-IYZbgYthPTspgqYeciRJNPhSwL51yer7HAwDXhF5p+H7mTDbPvY3PCk/QDjNxdPCpWkaJVFC4t7iCNB/t9E5Kw==} + /docdash@2.0.2: + resolution: {integrity: sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==} + dependencies: + '@jsdoc/salty': 0.2.12 + dev: true + /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -16483,6 +16511,10 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + /lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + dev: true + /log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -19185,6 +19217,10 @@ packages: /regenerator-runtime@0.14.0: resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: true + /regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} dependencies: @@ -20927,6 +20963,11 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + /uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + dev: true + /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true From 9894c628a8b26020ad09a05c1f0febff15421876 Mon Sep 17 00:00:00 2001 From: ikovic Date: Fri, 11 Sep 2026 15:56:32 +0200 Subject: [PATCH 2/4] Update the core sdk everywhere --- packages/demo-form/package.json | 2 +- packages/remix/package.json | 2 +- pnpm-lock.yaml | 46 +++------------------------------ 3 files changed, 6 insertions(+), 44 deletions(-) diff --git a/packages/demo-form/package.json b/packages/demo-form/package.json index 44aff4c8..80204451 100644 --- a/packages/demo-form/package.json +++ b/packages/demo-form/package.json @@ -17,7 +17,7 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^0.1.6", "@slashid/react": "workspace:*", - "@slashid/slashid": "3.25.0", + "@slashid/slashid": "3.30.0-hook-beta.1", "next": "13.0.2", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/packages/remix/package.json b/packages/remix/package.json index ca2c769d..446e0b77 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -10,7 +10,7 @@ "module": "dist/main.js", "dependencies": { "@slashid/react": "workspace:*", - "@slashid/slashid": "3.29.6", + "@slashid/slashid": "3.30.0-hook-beta.1", "jose": "^5.2.0", "url-join": "^5.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b8837ed..faae6508 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,8 +294,8 @@ importers: specifier: workspace:* version: link:../react '@slashid/slashid': - specifier: 3.25.0 - version: 3.25.0 + specifier: 3.30.0-hook-beta.1 + version: 3.30.0-hook-beta.1 next: specifier: 13.0.2 version: 13.0.2(@babel/core@7.24.5)(react-dom@18.2.0)(react@18.2.0) @@ -603,8 +603,8 @@ importers: specifier: workspace:* version: link:../react '@slashid/slashid': - specifier: 3.29.6 - version: 3.29.6 + specifier: 3.30.0-hook-beta.1 + version: 3.30.0-hook-beta.1 jose: specifier: ^5.2.0 version: 5.2.0 @@ -5089,7 +5089,6 @@ packages: engines: {node: '>=v12.0.0'} dependencies: lodash: 4.18.1 - dev: true /@jspm/core@2.0.1: resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} @@ -8734,23 +8733,6 @@ packages: uuid: 8.3.2 dev: false - /@slashid/slashid@3.25.0: - resolution: {integrity: sha512-fjqHL0Kx6JKWSCnekYx4vD8Mw7DY46+lcTDdRcoMDugx433pJ89VtmsWFW1U9XPV/R/9kK18rrideNAO3iN4fw==} - dependencies: - '@changesets/cli': 2.26.2 - '@types/uuid': 8.3.4 - changeset: 0.2.6 - compare-versions: 6.1.0 - docdash: 1.2.0 - jwt-decode: 3.1.2 - qrcode: 1.5.3 - querystring-es3: 0.2.1 - regenerator-runtime: 0.13.11 - ua-parser-js: 1.0.37 - url: 0.11.3 - uuid: 8.3.2 - dev: false - /@slashid/slashid@3.29.0: resolution: {integrity: sha512-kFW7dy3VIcp55U6tSIWB+t+x70HqKaI+TxQKL+/L5qQNgCLQGGXnWrj552HWlbAdiTN6h+eJbAw7HkxG/926kQ==} dependencies: @@ -8767,21 +8749,6 @@ packages: uuid: 8.3.2 dev: true - /@slashid/slashid@3.29.6: - resolution: {integrity: sha512-7hMONd6O5TbIyIQgrEyve18e0gJ67hv7YK733Ol/xwS+idbP8l3rqxQbz0zXEX30BJVmuW3CV/VxbL71ZHz67Q==} - dependencies: - '@types/uuid': 8.3.4 - compare-versions: 6.1.0 - docdash: 1.2.0 - jwt-decode: 3.1.2 - qrcode: 1.5.3 - querystring-es3: 0.2.1 - regenerator-runtime: 0.13.11 - ua-parser-js: 1.0.37 - url: 0.11.3 - uuid: 8.3.2 - dev: false - /@slashid/slashid@3.30.0-hook-beta.1: resolution: {integrity: sha512-wy1cDvAb8XRUe00hOzQVverFSYtH01xzFRAtvLESBfaB4AESC9ApYqRrlX006i+qEM8/xzhNvIzwAdK3nDBzoQ==} dependencies: @@ -8794,7 +8761,6 @@ packages: ua-parser-js: 1.0.37 url: 0.11.3 uuid: 11.1.1 - dev: true /@storybook/addon-actions@7.6.19: resolution: {integrity: sha512-ATLrA5QKFJt7tIAScRHz5T3eBQ+RG3jaZk08L7gChvyQZhei8knWwePElZ7GaWbCr9BgznQp1lQUUXq/UUblAQ==} @@ -13204,7 +13170,6 @@ packages: resolution: {integrity: sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==} dependencies: '@jsdoc/salty': 0.2.12 - dev: true /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -16513,7 +16478,6 @@ packages: /lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - dev: true /log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -19219,7 +19183,6 @@ packages: /regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - dev: true /regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} @@ -20966,7 +20929,6 @@ packages: /uuid@11.1.1: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - dev: true /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} From 5bcc5dc782fa9dfcd40b0afb7e89a6ad9a0c5e13 Mon Sep 17 00:00:00 2001 From: ikovic Date: Wed, 16 Sep 2026 16:48:53 +0200 Subject: [PATCH 3/4] Update to the next version of the core SDK --- .changeset/one-step-sso-hook.md | 2 +- packages/demo-form/package.json | 2 +- packages/react-primitives/package.json | 2 +- packages/react/package.json | 4 +- packages/remix/package.json | 2 +- pnpm-lock.yaml | 228 ++----------------------- 6 files changed, 19 insertions(+), 221 deletions(-) diff --git a/.changeset/one-step-sso-hook.md b/.changeset/one-step-sso-hook.md index 3e40f3e7..65634524 100644 --- a/.changeset/one-step-sso-hook.md +++ b/.changeset/one-step-sso-hook.md @@ -2,4 +2,4 @@ "@slashid/react": minor --- -`DynamicFlow` gains an `attemptSSO` prop. With it on, the `hook` factor is submitted for email identifiers before `getFactors` is consulted, so the organization's `identify_user` webhook can pick the factor (one-step SSO). When the API resolves nothing, the flow continues with `getFactors` for the same identifier. Requires `@slashid/slashid` with `HookFactorUnresolvedError`. +Add the internal `attemptSSO` prop to `` for one-step SSO. Requires `@slashid/slashid` 3.30.0 or later. diff --git a/packages/demo-form/package.json b/packages/demo-form/package.json index 80204451..8d17d427 100644 --- a/packages/demo-form/package.json +++ b/packages/demo-form/package.json @@ -17,7 +17,7 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^0.1.6", "@slashid/react": "workspace:*", - "@slashid/slashid": "3.30.0-hook-beta.1", + "@slashid/slashid": "3.30.0", "next": "13.0.2", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/packages/react-primitives/package.json b/packages/react-primitives/package.json index 6fc5efbe..92c3b327 100644 --- a/packages/react-primitives/package.json +++ b/packages/react-primitives/package.json @@ -76,7 +76,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.0.2", - "@slashid/slashid": "3.29.0", + "@slashid/slashid": "3.30.0", "@storybook/addon-essentials": "7.6.19", "@storybook/addon-interactions": "7.4.0", "@storybook/addon-links": "7.4.0", diff --git a/packages/react/package.json b/packages/react/package.json index f4f5c7fc..5a765081 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.0.2", - "@slashid/slashid": "3.30.0-hook-beta.1", + "@slashid/slashid": "3.30.0", "@storybook/addon-essentials": "7.6.19", "@storybook/addon-interactions": "7.4.0", "@storybook/addon-links": "7.4.0", @@ -95,7 +95,7 @@ "yalc": "1.0.0-pre.53" }, "peerDependencies": { - "@slashid/slashid": ">= 3.29.6", + "@slashid/slashid": ">= 3.30.0", "react": ">=16", "react-dom": ">=16" } diff --git a/packages/remix/package.json b/packages/remix/package.json index 446e0b77..243404a2 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -10,7 +10,7 @@ "module": "dist/main.js", "dependencies": { "@slashid/react": "workspace:*", - "@slashid/slashid": "3.30.0-hook-beta.1", + "@slashid/slashid": "3.30.0", "jose": "^5.2.0", "url-join": "^5.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faae6508..97340743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,8 +294,8 @@ importers: specifier: workspace:* version: link:../react '@slashid/slashid': - specifier: 3.30.0-hook-beta.1 - version: 3.30.0-hook-beta.1 + specifier: 3.30.0 + version: 3.30.0 next: specifier: 13.0.2 version: 13.0.2(@babel/core@7.24.5)(react-dom@18.2.0)(react@18.2.0) @@ -353,8 +353,8 @@ importers: specifier: ^8.0.2 version: 8.3.1 '@slashid/slashid': - specifier: 3.30.0-hook-beta.1 - version: 3.30.0-hook-beta.1 + specifier: 3.30.0 + version: 3.30.0 '@storybook/addon-essentials': specifier: 7.6.19 version: 7.6.19(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) @@ -498,8 +498,8 @@ importers: specifier: ^8.0.2 version: 8.3.1 '@slashid/slashid': - specifier: 3.29.0 - version: 3.29.0 + specifier: 3.30.0 + version: 3.30.0 '@storybook/addon-essentials': specifier: 7.6.19 version: 7.6.19(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) @@ -603,8 +603,8 @@ importers: specifier: workspace:* version: link:../react '@slashid/slashid': - specifier: 3.30.0-hook-beta.1 - version: 3.30.0-hook-beta.1 + specifier: 3.30.0 + version: 3.30.0 jose: specifier: ^5.2.0 version: 5.2.0 @@ -3426,25 +3426,6 @@ packages: resolve-from: 5.0.0 semver: 7.5.4 - /@changesets/apply-release-plan@7.0.4: - resolution: {integrity: sha512-HLFwhKWayKinWAul0Vj+76jVx1Pc2v55MGPVjZ924Y/ROeSsBMFutv9heHmCUj48lJyRfOTJG5+ar+29FUky/A==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/config': 3.0.2 - '@changesets/get-version-range-type': 0.4.0 - '@changesets/git': 3.0.0 - '@changesets/should-skip-package': 0.1.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - detect-indent: 6.1.0 - fs-extra: 7.0.1 - lodash.startcase: 4.4.0 - outdent: 0.5.0 - prettier: 2.8.8 - resolve-from: 5.0.0 - semver: 7.5.4 - dev: true - /@changesets/assemble-release-plan@5.2.4: resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==} dependencies: @@ -3455,29 +3436,11 @@ packages: '@manypkg/get-packages': 1.1.3 semver: 7.5.4 - /@changesets/assemble-release-plan@6.0.3: - resolution: {integrity: sha512-bLNh9/Lgl1VwkjWZTq8JmRqH+hj7/Yzfz0jsQ/zJJ+FTmVqmqPj3szeKOri8O/hEM8JmHW019vh2gTO9iq5Cuw==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.1 - '@changesets/should-skip-package': 0.1.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - semver: 7.5.4 - dev: true - /@changesets/changelog-git@0.1.14: resolution: {integrity: sha512-+vRfnKtXVWsDDxGctOfzJsPhaCdXRYoe+KyWYoq5X/GqoISREiat0l3L8B0a453B2B4dfHGcZaGyowHbp9BSaA==} dependencies: '@changesets/types': 5.2.1 - /@changesets/changelog-git@0.2.0: - resolution: {integrity: sha512-bHOx97iFI4OClIT35Lok3sJAwM31VbUM++gnMBV16fdbtBhgYu4dxsphBF/0AZZsyAHMrnM0yFcj5gZM1py6uQ==} - dependencies: - '@changesets/types': 6.0.0 - dev: true - /@changesets/cli@2.26.2: resolution: {integrity: sha512-dnWrJTmRR8bCHikJHl9b9HW3gXACCehz4OasrXpMp7sx97ECuBGGNjJhjPhdZNCvMy9mn4BWdplI323IbqsRig==} dependencies: @@ -3515,44 +3478,6 @@ packages: term-size: 2.2.1 tty-table: 4.2.3 - /@changesets/cli@2.27.7: - resolution: {integrity: sha512-6lr8JltiiXPIjDeYg4iM2MeePP6VN/JkmqBsVA5XRiy01hGS3y629LtSDvKcycj/w/5Eur1rEwby/MjcYS+e2A==} - hasBin: true - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/apply-release-plan': 7.0.4 - '@changesets/assemble-release-plan': 6.0.3 - '@changesets/changelog-git': 0.2.0 - '@changesets/config': 3.0.2 - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.1 - '@changesets/get-release-plan': 4.0.3 - '@changesets/git': 3.0.0 - '@changesets/logger': 0.1.0 - '@changesets/pre': 2.0.0 - '@changesets/read': 0.6.0 - '@changesets/should-skip-package': 0.1.0 - '@changesets/types': 6.0.0 - '@changesets/write': 0.3.1 - '@manypkg/get-packages': 1.1.3 - '@types/semver': 7.5.5 - ansi-colors: 4.1.3 - chalk: 2.4.2 - ci-info: 3.9.0 - enquirer: 2.4.1 - external-editor: 3.1.0 - fs-extra: 7.0.1 - human-id: 1.0.2 - mri: 1.2.0 - outdent: 0.5.0 - p-limit: 2.3.0 - preferred-pm: 3.1.2 - resolve-from: 5.0.0 - semver: 7.5.4 - spawndamnit: 2.0.0 - term-size: 2.2.1 - dev: true - /@changesets/config@2.3.1: resolution: {integrity: sha512-PQXaJl82CfIXddUOppj4zWu+987GCw2M+eQcOepxN5s+kvnsZOwjEJO3DH9eVy+OP6Pg/KFEWdsECFEYTtbg6w==} dependencies: @@ -3564,29 +3489,11 @@ packages: fs-extra: 7.0.1 micromatch: 4.0.5 - /@changesets/config@3.0.2: - resolution: {integrity: sha512-cdEhS4t8woKCX2M8AotcV2BOWnBp09sqICxKapgLHf9m5KdENpWjyrFNMjkLqGJtUys9U+w93OxWT0czorVDfw==} - dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.1 - '@changesets/logger': 0.1.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - fs-extra: 7.0.1 - micromatch: 4.0.5 - dev: true - /@changesets/errors@0.1.4: resolution: {integrity: sha512-HAcqPF7snsUJ/QzkWoKfRfXushHTu+K5KZLJWPb34s4eCZShIf8BFO3fwq6KU8+G7L5KdtN2BzQAXOSXEyiY9Q==} dependencies: extendable-error: 0.1.7 - /@changesets/errors@0.2.0: - resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - dependencies: - extendable-error: 0.1.7 - dev: true - /@changesets/get-dependents-graph@1.3.6: resolution: {integrity: sha512-Q/sLgBANmkvUm09GgRsAvEtY3p1/5OCzgBE5vX3vgb5CvW0j7CEljocx5oPXeQSNph6FXulJlXV3Re/v3K3P3Q==} dependencies: @@ -3596,16 +3503,6 @@ packages: fs-extra: 7.0.1 semver: 7.5.4 - /@changesets/get-dependents-graph@2.1.1: - resolution: {integrity: sha512-LRFjjvigBSzfnPU2n/AhFsuWR5DK++1x47aq6qZ8dzYsPtS/I5mNhIGAS68IAxh1xjO9BTtz55FwefhANZ+FCA==} - dependencies: - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - chalk: 2.4.2 - fs-extra: 7.0.1 - semver: 7.5.4 - dev: true - /@changesets/get-release-plan@3.0.17: resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==} dependencies: @@ -3617,25 +3514,9 @@ packages: '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 - /@changesets/get-release-plan@4.0.3: - resolution: {integrity: sha512-6PLgvOIwTSdJPTtpdcr3sLtGatT+Jr22+cQwEBJBy6wP0rjB4yJ9lv583J9fVpn1bfQlBkDa8JxbS2g/n9lIyA==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/assemble-release-plan': 6.0.3 - '@changesets/config': 3.0.2 - '@changesets/pre': 2.0.0 - '@changesets/read': 0.6.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - dev: true - /@changesets/get-version-range-type@0.3.2: resolution: {integrity: sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg==} - /@changesets/get-version-range-type@0.4.0: - resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} - dev: true - /@changesets/git@2.0.0: resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==} dependencies: @@ -3647,42 +3528,17 @@ packages: micromatch: 4.0.5 spawndamnit: 2.0.0 - /@changesets/git@3.0.0: - resolution: {integrity: sha512-vvhnZDHe2eiBNRFHEgMiGd2CT+164dfYyrJDhwwxTVD/OW0FUD6G7+4DIx1dNwkwjHyzisxGAU96q0sVNBns0w==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/errors': 0.2.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - is-subdir: 1.2.0 - micromatch: 4.0.5 - spawndamnit: 2.0.0 - dev: true - /@changesets/logger@0.0.5: resolution: {integrity: sha512-gJyZHomu8nASHpaANzc6bkQMO9gU/ib20lqew1rVx753FOxffnCrJlGIeQVxNWCqM+o6OOleCo/ivL8UAO5iFw==} dependencies: chalk: 2.4.2 - /@changesets/logger@0.1.0: - resolution: {integrity: sha512-pBrJm4CQm9VqFVwWnSqKEfsS2ESnwqwH+xR7jETxIErZcfd1u2zBSqrHbRHR7xjhSgep9x2PSKFKY//FAshA3g==} - dependencies: - chalk: 2.4.2 - dev: true - /@changesets/parse@0.3.16: resolution: {integrity: sha512-127JKNd167ayAuBjUggZBkmDS5fIKsthnr9jr6bdnuUljroiERW7FBTDNnNVyJ4l69PzR57pk6mXQdtJyBCJKg==} dependencies: '@changesets/types': 5.2.1 js-yaml: 3.14.1 - /@changesets/parse@0.4.0: - resolution: {integrity: sha512-TS/9KG2CdGXS27S+QxbZXgr8uPsP4yNJYb4BC2/NeFUj80Rni3TeD2qwWmabymxmrLo7JEsytXH1FbpKTbvivw==} - dependencies: - '@changesets/types': 6.0.0 - js-yaml: 3.14.1 - dev: true - /@changesets/pre@1.0.14: resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==} dependencies: @@ -3692,16 +3548,6 @@ packages: '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - /@changesets/pre@2.0.0: - resolution: {integrity: sha512-HLTNYX/A4jZxc+Sq8D1AMBsv+1qD6rmmJtjsCJa/9MSRybdxh0mjbTvE6JYZQ/ZiQ0mMlDOlGPXTm9KLTU3jyw==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/errors': 0.2.0 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - fs-extra: 7.0.1 - dev: true - /@changesets/read@0.5.9: resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==} dependencies: @@ -3714,37 +3560,12 @@ packages: fs-extra: 7.0.1 p-filter: 2.1.0 - /@changesets/read@0.6.0: - resolution: {integrity: sha512-ZypqX8+/im1Fm98K4YcZtmLKgjs1kDQ5zHpc2U1qdtNBmZZfo/IBiG162RoP0CUF05tvp2y4IspH11PLnPxuuw==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/git': 3.0.0 - '@changesets/logger': 0.1.0 - '@changesets/parse': 0.4.0 - '@changesets/types': 6.0.0 - chalk: 2.4.2 - fs-extra: 7.0.1 - p-filter: 2.1.0 - dev: true - - /@changesets/should-skip-package@0.1.0: - resolution: {integrity: sha512-FxG6Mhjw7yFStlSM7Z0Gmg3RiyQ98d/9VpQAZ3Fzr59dCOM9G6ZdYbjiSAt0XtFr9JR5U2tBaJWPjrkGGc618g==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/types': 6.0.0 - '@manypkg/get-packages': 1.1.3 - dev: true - /@changesets/types@4.1.0: resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} /@changesets/types@5.2.1: resolution: {integrity: sha512-myLfHbVOqaq9UtUKqR/nZA/OY7xFjQMdfgfqeZIBK4d0hA6pgxArvdv8M+6NUzzBsjWLOtvApv8YHr4qM+Kpfg==} - /@changesets/types@6.0.0: - resolution: {integrity: sha512-b1UkfNulgKoWfqyHtzKS5fOZYSJO+77adgL7DLRDr+/7jhChN+QcHnbjiQVOz/U+Ts3PGNySq7diAItzDgugfQ==} - dev: true - /@changesets/write@0.2.3: resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} dependencies: @@ -3754,16 +3575,6 @@ packages: human-id: 1.0.2 prettier: 2.8.8 - /@changesets/write@0.3.1: - resolution: {integrity: sha512-SyGtMXzH3qFqlHKcvFY2eX+6b0NGiFcNav8AFsYwy5l8hejOeoeTDemu5Yjmke2V5jpzY+pBvM0vCCQ3gdZpfw==} - dependencies: - '@babel/runtime': 7.26.10 - '@changesets/types': 6.0.0 - fs-extra: 7.0.1 - human-id: 1.0.2 - prettier: 2.8.8 - dev: true - /@colors/colors@1.5.0: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -8733,24 +8544,8 @@ packages: uuid: 8.3.2 dev: false - /@slashid/slashid@3.29.0: - resolution: {integrity: sha512-kFW7dy3VIcp55U6tSIWB+t+x70HqKaI+TxQKL+/L5qQNgCLQGGXnWrj552HWlbAdiTN6h+eJbAw7HkxG/926kQ==} - dependencies: - '@changesets/cli': 2.27.7 - '@types/uuid': 8.3.4 - compare-versions: 6.1.0 - docdash: 1.2.0 - jwt-decode: 3.1.2 - qrcode: 1.5.3 - querystring-es3: 0.2.1 - regenerator-runtime: 0.13.11 - ua-parser-js: 1.0.37 - url: 0.11.3 - uuid: 8.3.2 - dev: true - - /@slashid/slashid@3.30.0-hook-beta.1: - resolution: {integrity: sha512-wy1cDvAb8XRUe00hOzQVverFSYtH01xzFRAtvLESBfaB4AESC9ApYqRrlX006i+qEM8/xzhNvIzwAdK3nDBzoQ==} + /@slashid/slashid@3.30.0: + resolution: {integrity: sha512-ee7DSUXbl6odI25+ybzJjcZHa+wcwKN9JIwW1Pawo9orAdTIEoSyHlGTwKrNL3sD/hQMDv1yJ+/B3dtEtHSZzQ==} dependencies: compare-versions: 6.1.0 docdash: 2.0.2 @@ -10632,6 +10427,7 @@ packages: /@types/uuid@8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + dev: false /@types/uuid@9.0.8: resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -13165,6 +12961,7 @@ packages: /docdash@1.2.0: resolution: {integrity: sha512-IYZbgYthPTspgqYeciRJNPhSwL51yer7HAwDXhF5p+H7mTDbPvY3PCk/QDjNxdPCpWkaJVFC4t7iCNB/t9E5Kw==} + dev: false /docdash@2.0.2: resolution: {integrity: sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==} @@ -20933,6 +20730,7 @@ packages: /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + dev: false /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} From 98a876f109470fb0ce2fa1078208cde42054ee23 Mon Sep 17 00:00:00 2001 From: ikovic Date: Wed, 16 Sep 2026 18:59:53 +0200 Subject: [PATCH 4/4] Refactor and make more robust --- .changeset/dynamic-flow-factor-resolution.md | 5 + .changeset/dynamic-flow-sso-middleware.md | 5 + .changeset/release-flow-subscriptions.md | 5 + packages/react/README.md | 8 + .../dynamic-flow/dynamic-flow.test.tsx | 347 +++++++++++++++++- .../src/components/dynamic-flow/index.tsx | 93 ++--- .../dynamic-flow/initial-state.test.ts | 127 +++++++ .../components/dynamic-flow/initial-state.ts | 65 ++++ .../src/components/dynamic-flow/initial.tsx | 162 +++++--- .../dynamic-flow/strict-mode.test.tsx | 135 +++++++ .../authenticating/authenticating.test.tsx | 141 +++++++ .../components/form/authenticating/index.tsx | 32 +- .../components/form/flow/auth-flow.test.ts | 258 +++++++++++++ .../src/components/form/flow/auth-flow.ts | 31 +- .../src/components/form/flow/flow.common.ts | 13 +- .../form/flow/org-switching-flow.test.ts | 75 ++++ .../form/flow/org-switching-flow.ts | 2 +- packages/react/src/components/test-utils.ts | 6 + packages/react/src/domain/handles.test.ts | 13 - packages/react/src/domain/handles.ts | 13 - 20 files changed, 1374 insertions(+), 162 deletions(-) create mode 100644 .changeset/dynamic-flow-factor-resolution.md create mode 100644 .changeset/dynamic-flow-sso-middleware.md create mode 100644 .changeset/release-flow-subscriptions.md create mode 100644 packages/react/src/components/dynamic-flow/initial-state.test.ts create mode 100644 packages/react/src/components/dynamic-flow/initial-state.ts create mode 100644 packages/react/src/components/dynamic-flow/strict-mode.test.tsx create mode 100644 packages/react/src/components/form/flow/auth-flow.test.ts create mode 100644 packages/react/src/components/form/flow/org-switching-flow.test.ts diff --git a/.changeset/dynamic-flow-factor-resolution.md b/.changeset/dynamic-flow-factor-resolution.md new file mode 100644 index 00000000..2f52efdc --- /dev/null +++ b/.changeset/dynamic-flow-factor-resolution.md @@ -0,0 +1,5 @@ +--- +"@slashid/react": patch +--- + +Tell the user when `` cannot resolve any factor, always offer a way back to the identifier step, and keep an in-flight `getFactors` result when the parent re-renders diff --git a/.changeset/dynamic-flow-sso-middleware.md b/.changeset/dynamic-flow-sso-middleware.md new file mode 100644 index 00000000..07c60b4f --- /dev/null +++ b/.changeset/dynamic-flow-sso-middleware.md @@ -0,0 +1,5 @@ +--- +"@slashid/react": patch +--- + +Apply `middleware` to the SSO provider buttons in `` diff --git a/.changeset/release-flow-subscriptions.md b/.changeset/release-flow-subscriptions.md new file mode 100644 index 00000000..0df0a28f --- /dev/null +++ b/.changeset/release-flow-subscriptions.md @@ -0,0 +1,5 @@ +--- +"@slashid/react": patch +--- + +Keep the authenticating step's challenge listener alive across React StrictMode re-runs, release it on unmount, and fix the inverted observer filter in the login flows diff --git a/packages/react/README.md b/packages/react/README.md index 0215c654..6a6653db 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -78,6 +78,14 @@ Once the `logIn` function resolves, your component will render again with the ne `DynamicFlow` asks for an identifier first and then picks the factors to offer from the `getFactors` callback. +`getFactors` decides what happens next: + +- Two or more factors show the picker. +- A single factor is submitted directly, skipping the picker. +- An empty array means there is no way to sign this person in. The user is told so and sent back to the identifier step. + +If `getFactors` throws, the user is shown a generic error with a retry button that calls it again with the same identifier. The rejection itself is not forwarded — catch it inside `getFactors` if you need to report or log it. + #### One-step SSO (`attemptSSO`) ```tsx diff --git a/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx b/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx index 7e5a58cd..01461fa5 100644 --- a/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx +++ b/packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx @@ -2,7 +2,12 @@ import { Errors, Factor, PersonHandle, User } from "@slashid/slashid"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi, describe } from "vitest"; -import { createTestUser, inputEmail, MockSlashID } from "../test-utils"; +import { useState } from "react"; +import Deferred, { + createTestUser, + inputEmail, + MockSlashID, +} from "../test-utils"; import { TestSlashIDProvider } from "../../context/test-providers"; import { DynamicFlow } from "."; @@ -530,4 +535,344 @@ describe("#DynamicFlow", () => { ).not.toBeInTheDocument(); expect(onError).toHaveBeenCalledTimes(1); }); + describe("when factor resolution produces nothing usable", () => { + test("shows the no-method screen when getFactors returns nothing", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const onError = vi.fn(); + const user = userEvent.setup(); + + render( + + + []} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveAttribute("data-reason", "no_factors"); + expect( + screen.getByText(TEXT["error.title.hookFactorUnresolved"]) + ).toBeInTheDocument(); + expect(onError).not.toHaveBeenCalled(); + + await user.click(screen.getByTestId("sid-dynamic-flow--failed-cta")); + expect( + screen.getByPlaceholderText(TEXT["initial.handle.email.placeholder"]) + ).toBeInTheDocument(); + }); + + test("shows the generic error screen when getFactors throws", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const getFactors = vi + .fn<[], Factor[]>() + .mockImplementationOnce(() => { + throw new Error("factor service down"); + }) + .mockImplementation(() => [ + { method: "email_link" }, + { method: "password" }, + ]); + const onError = vi.fn(); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveAttribute("data-reason", "resolve_error"); + expect( + screen.queryByTestId("sid-dynamic-flow--resolving-factors") + ).not.toBeInTheDocument(); + expect(onError).not.toHaveBeenCalled(); + + await user.click(screen.getByTestId("sid-dynamic-flow--failed-cta")); + await expect( + screen.findByTestId("sid-dynamic-flow--resolved-factors") + ).resolves.toBeInTheDocument(); + expect(getFactors).toHaveBeenCalledTimes(2); + }); + + test("handles an empty getFactors result without attemptSSO", async () => { + const logInMock = vi.fn(() => Promise.resolve(createTestUser())); + const user = userEvent.setup(); + + render( + + + []} /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveAttribute("data-reason", "no_factors"); + expect(logInMock).not.toHaveBeenCalled(); + }); + + test("handles getFactors throwing without attemptSSO", async () => { + const logInMock = vi.fn(() => Promise.resolve(createTestUser())); + const user = userEvent.setup(); + + render( + + + Promise.reject(new Error("boom"))} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveAttribute("data-reason", "resolve_error"); + expect(logInMock).not.toHaveBeenCalled(); + }); + }); + + describe("an SSO attempt that fails for another reason", () => { + test("retrying re-submits the hook factor", async () => { + const logInMock = vi.fn(() => Promise.reject(new Error("network down"))); + const getFactors = vi.fn(() => [] as Factor[]); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await user.click(await screen.findByTestId("sid-form-error-retry-button")); + + await vi.waitFor(() => expect(logInMock).toHaveBeenCalledTimes(2)); + expect(logInMock).toHaveBeenLastCalledWith( + { + factor: { method: "hook" }, + handle: { type: "email_address", value: "user@acme.test" }, + }, + { middleware: undefined } + ); + expect(getFactors).not.toHaveBeenCalled(); + }); + + test("a retry that comes back unresolved falls through to the picker", async () => { + const logInMock = vi + .fn() + .mockImplementationOnce(() => Promise.reject(new Error("network down"))) + .mockImplementation(() => Promise.reject(hookUnresolved())); + const getFactors = vi.fn( + () => [{ method: "email_link" }, { method: "password" }] as Factor[] + ); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await user.click(await screen.findByTestId("sid-form-error-retry-button")); + + await expect( + screen.findByTestId("sid-dynamic-flow--resolved-factors") + ).resolves.toBeInTheDocument(); + expect(getFactors).toHaveBeenCalledTimes(1); + }); + }); + + describe("attempt bookkeeping", () => { + test("submits the hook factor exactly once and resolves factors once", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const getFactors = vi.fn( + () => [{ method: "email_link" }, { method: "password" }] as Factor[] + ); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + await screen.findByTestId("sid-dynamic-flow--resolved-factors"); + + expect(logInMock).toHaveBeenCalledTimes(1); + expect(getFactors).toHaveBeenCalledTimes(1); + }); + + test("going back and resubmitting the same handle attempts SSO again", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const user = userEvent.setup(); + + render( + + + [ + { method: "email_link" }, + { method: "password" }, + ]} + /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + await screen.findByTestId("sid-dynamic-flow--resolved-factors"); + + await user.click( + screen.getByTestId("sid-form-authenticating-cancel-button") + ); + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await vi.waitFor(() => expect(logInMock).toHaveBeenCalledTimes(2)); + expect(logInMock).toHaveBeenLastCalledWith( + expect.objectContaining({ factor: { method: "hook" } }), + { middleware: undefined } + ); + }); + + test("never submits the hook factor when attemptSSO is off", async () => { + const logInMock = vi.fn(() => Promise.resolve(createTestUser())); + const user = userEvent.setup(); + + render( + + + [{ method: "email_link" }]} /> + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await vi.waitFor(() => expect(logInMock).toHaveBeenCalled()); + expect(logInMock).not.toHaveBeenCalledWith( + expect.objectContaining({ factor: { method: "hook" } }), + expect.anything() + ); + }); + }); + describe("regressions caught by the second review", () => { + test("the resolve_error screen can always go back to the identifier step", async () => { + const logInMock = vi.fn(() => Promise.reject(hookUnresolved())); + const getFactors = vi.fn(() => Promise.reject(new Error("always down"))); + const user = userEvent.setup(); + + render( + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveAttribute("data-reason", "resolve_error"); + + await user.click(screen.getByTestId("sid-dynamic-flow--failed-cta")); + await expect( + screen.findByTestId("sid-dynamic-flow--failed") + ).resolves.toHaveAttribute("data-reason", "resolve_error"); + + await user.click( + screen.getByTestId("sid-form-authenticating-cancel-button") + ); + expect( + screen.getByPlaceholderText(TEXT["initial.handle.email.placeholder"]) + ).toBeInTheDocument(); + }); + + test("a parent re-render with an inline getFactors does not discard the in-flight result", async () => { + const deferreds: Deferred[] = []; + const getFactors = vi.fn(() => { + const d = new Deferred(); + deferreds.push(d); + return d; + }); + const user = userEvent.setup(); + + function Host() { + const [, bump] = useState(0); + return ( + <> +
diff --git a/packages/react/src/components/dynamic-flow/initial-state.test.ts b/packages/react/src/components/dynamic-flow/initial-state.test.ts new file mode 100644 index 00000000..edcc79cc --- /dev/null +++ b/packages/react/src/components/dynamic-flow/initial-state.test.ts @@ -0,0 +1,127 @@ +import { Factor } from "@slashid/slashid"; +import { describe, expect, test } from "vitest"; + +import { Handle } from "../../domain/types"; +import { Action, init, reducer, Step } from "./initial-state"; + +const EMAIL: Handle = { type: "email_address", value: "user@acme.test" }; +const PHONE: Handle = { type: "phone_number", value: "+15550100" }; +const FACTORS: Factor[] = [{ method: "email_link" }, { method: "password" }]; + +describe("init", () => { + test("starts idle without a resumed handle", () => { + expect(init()).toEqual({ step: "idle" }); + }); + + test("starts at factor resolution with a resumed handle", () => { + expect(init(EMAIL)).toEqual({ step: "resolving_factors", handle: EMAIL }); + }); +}); + +describe("reducer", () => { + describe("submit_handle", () => { + test("attempts SSO for an email address when enabled", () => { + const next = reducer(init(), { + type: "submit_handle", + handle: EMAIL, + attemptSSO: true, + }); + + expect(next).toEqual({ step: "attempting_sso", handle: EMAIL }); + }); + + test("skips SSO for a non-email handle", () => { + const next = reducer(init(), { + type: "submit_handle", + handle: PHONE, + attemptSSO: true, + }); + + expect(next).toEqual({ step: "resolving_factors", handle: PHONE }); + }); + + test("skips SSO when disabled", () => { + const next = reducer(init(), { + type: "submit_handle", + handle: EMAIL, + attemptSSO: false, + }); + + expect(next).toEqual({ step: "resolving_factors", handle: EMAIL }); + }); + }); + + test("factors_resolved moves to the picker", () => { + const next = reducer(init(EMAIL), { + type: "factors_resolved", + factors: FACTORS, + }); + + expect(next).toEqual({ + step: "picking", + handle: EMAIL, + factors: FACTORS, + }); + }); + + test.each(["no_factors", "resolve_error"] as const)( + "resolve_failed moves to failed with reason %s", + (reason) => { + const next = reducer(init(EMAIL), { type: "resolve_failed", reason }); + + expect(next).toEqual({ step: "failed", handle: EMAIL, reason }); + } + ); + + test("retry_resolution goes back to resolving the same handle", () => { + const failed = reducer(init(EMAIL), { + type: "resolve_failed", + reason: "resolve_error", + }); + + expect(reducer(failed, { type: "retry_resolution" })).toEqual({ + step: "resolving_factors", + handle: EMAIL, + }); + }); + + describe("reset", () => { + test("returns to idle without a resumed handle", () => { + const picking = reducer(init(EMAIL), { + type: "factors_resolved", + factors: FACTORS, + }); + + expect(reducer(picking, { type: "reset" })).toEqual({ step: "idle" }); + }); + + test("returns to factor resolution with a resumed handle", () => { + expect(reducer(init(), { type: "reset", resumedHandle: EMAIL })).toEqual({ + step: "resolving_factors", + handle: EMAIL, + }); + }); + }); + + test("a resumed step can never reach attempting_sso", () => { + const resumed = init(EMAIL); + const actions: Action[] = [ + { type: "factors_resolved", factors: FACTORS }, + { type: "resolve_failed", reason: "no_factors" }, + { type: "resolve_failed", reason: "resolve_error" }, + { type: "retry_resolution" }, + { type: "reset", resumedHandle: EMAIL }, + { type: "reset" }, + ]; + + const reachable = new Set(); + const visit = (state: Step, depth: number) => { + reachable.add(state.step); + if (depth === 0) return; + actions.forEach((action) => visit(reducer(state, action), depth - 1)); + }; + visit(resumed, 4); + + expect(reachable.has("attempting_sso")).toBe(false); + }); +}); diff --git a/packages/react/src/components/dynamic-flow/initial-state.ts b/packages/react/src/components/dynamic-flow/initial-state.ts new file mode 100644 index 00000000..4f35b28a --- /dev/null +++ b/packages/react/src/components/dynamic-flow/initial-state.ts @@ -0,0 +1,65 @@ +import { Factor } from "@slashid/slashid"; +import { Handle } from "../../domain/types"; + +export type FailureReason = "no_factors" | "resolve_error"; + +/** + * `picking` is only reachable with two or more factors - a single factor is + * submitted directly and none is a failure - so an empty picker cannot be + * represented. `resolving_factors` has no transition to `attempting_sso`, + * which is what stops a resumed step from attempting SSO a second time. + */ +export type Step = + | { step: "idle" } + | { step: "attempting_sso"; handle: Handle } + | { step: "resolving_factors"; handle: Handle } + | { step: "picking"; handle: Handle; factors: Factor[] } + | { step: "failed"; handle: Handle; reason: FailureReason }; + +export type Action = + | { type: "submit_handle"; handle: Handle; attemptSSO: boolean } + | { type: "factors_resolved"; factors: Factor[] } + | { type: "resolve_failed"; reason: FailureReason } + | { type: "retry_resolution" } + | { type: "reset"; resumedHandle?: Handle }; + +export function init(resumedHandle?: Handle): Step { + return resumedHandle + ? { step: "resolving_factors", handle: resumedHandle } + : { step: "idle" }; +} + +export function shouldAttemptSSO( + handle: Handle, + attemptSSO: boolean | undefined +): boolean { + return !!attemptSSO && handle.type === "email_address"; +} + +export function reducer(state: Step, action: Action): Step { + switch (action.type) { + case "submit_handle": + return shouldAttemptSSO(action.handle, action.attemptSSO) + ? { step: "attempting_sso", handle: action.handle } + : { step: "resolving_factors", handle: action.handle }; + + case "factors_resolved": + if (state.step !== "resolving_factors") return state; + return { + step: "picking", + handle: state.handle, + factors: action.factors, + }; + + case "resolve_failed": + if (state.step !== "resolving_factors") return state; + return { step: "failed", handle: state.handle, reason: action.reason }; + + case "retry_resolution": + if (state.step !== "failed") return state; + return { step: "resolving_factors", handle: state.handle }; + + case "reset": + return init(action.resumedHandle); + } +} diff --git a/packages/react/src/components/dynamic-flow/initial.tsx b/packages/react/src/components/dynamic-flow/initial.tsx index c15966cb..abd29ce5 100644 --- a/packages/react/src/components/dynamic-flow/initial.tsx +++ b/packages/react/src/components/dynamic-flow/initial.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useReducer, useRef } from "react"; import { Factor } from "@slashid/slashid"; -import { Divider } from "@slashid/react-primitives"; +import { Button, Divider } from "@slashid/react-primitives"; import { FormProvider } from "../../context/form-context"; import { InitialState } from "../form/flow/flow.common"; @@ -13,7 +13,6 @@ import { hasSSOAndNonSSOFactors, isFactorSSO, resolveLastHandleValue, - shouldAttemptSSO, } from "../../domain/handles"; import * as styles from "./dynamic-flow.css"; @@ -21,6 +20,7 @@ import { HandleForm } from "./handle-form"; import { Loader } from "../form/authenticating/icons"; import { useInternalFormContext } from "../form/internal-context"; import { BackButton } from "../form/authenticating/authenticating.components"; +import { FailureReason, init, reducer } from "./initial-state"; type Props = { flowState: InitialState; @@ -28,90 +28,94 @@ type Props = { getFactors: (handle: Handle) => Promise | Factor[]; middleware?: LoginOptions["middleware"]; attemptSSO?: boolean; - /** Start at factor resolution for this handle; no SSO attempt is made for it. */ - initialHandle?: Handle; - onSSOAttempt?: (handle: Handle) => void; }; -type PreAuthState = "idle" | "resolving_factors" | "resolved_factors"; - export const Initial = ({ flowState, handleSubmit, middleware, getFactors, attemptSSO, - initialHandle, - onSSOAttempt, }: Props) => { - const [handle, setHandle] = useState(initialHandle); - const [preAuthState, setPreAuthState] = useState( - initialHandle ? "resolving_factors" : "idle" - ); - const [factors, setFactors] = useState(); + const [state, dispatch] = useReducer(reducer, flowState.resumedHandle, init); const previousFlowState = useRef(flowState); + // a new initial state means the flow moved; the mount run is not a move useEffect(() => { - (async () => { - if (!handle || preAuthState !== "resolving_factors") return; + if (previousFlowState.current === flowState) return; + previousFlowState.current = flowState; + dispatch({ type: "reset", resumedHandle: flowState.resumedHandle }); + }, [flowState]); - if (shouldAttemptSSO(handle, attemptSSO, initialHandle)) { - onSSOAttempt?.(handle); - handleSubmit({ method: "hook" }, handle); - return; - } + useEffect(() => { + if (state.step !== "attempting_sso") return; + handleSubmit({ method: "hook" }, state.handle); + }, [state, handleSubmit]); - const f = await getFactors(handle); - if (f.length === 1) { - handleSubmit(f[0], handle); - return; - } + useEffect(() => { + if (state.step !== "resolving_factors") return; + const { handle } = state; + let cancelled = false; - setFactors(f); - setPreAuthState("resolved_factors"); + (async () => { + try { + const factors = await getFactors(handle); + if (cancelled) return; + + if (factors.length === 0) { + dispatch({ type: "resolve_failed", reason: "no_factors" }); + } else if (factors.length === 1) { + handleSubmit(factors[0], handle); + } else { + dispatch({ type: "factors_resolved", factors }); + } + } catch { + if (cancelled) return; + dispatch({ type: "resolve_failed", reason: "resolve_error" }); + } })(); - }, [ - attemptSSO, - getFactors, - handle, - handleSubmit, - initialHandle, - onSSOAttempt, - preAuthState, - ]); - - // reset on back action (flow cancellation); the mount run is skipped so a - // resumed instance is not bounced back to idle - useEffect(() => { - if (previousFlowState.current === flowState) return; - previousFlowState.current = flowState; - setPreAuthState("idle"); - }, [flowState]); + + return () => { + cancelled = true; + }; + }, [state, getFactors, handleSubmit]); return (
- {preAuthState === "idle" && ( + {state.step === "idle" && ( { - setHandle(handle); - setPreAuthState("resolving_factors"); + if (!handle) return; + dispatch({ + type: "submit_handle", + handle, + attemptSSO: !!attemptSSO, + }); }} /> )} - {preAuthState === "resolving_factors" && } - {factors && preAuthState === "resolved_factors" && ( + {(state.step === "attempting_sso" || + state.step === "resolving_factors") && } + {state.step === "picking" && ( { - handleSubmit(factor, handle); + handleSubmit(factor, state.handle); }} - factors={factors} + factors={state.factors} middleware={middleware} /> )} + {state.step === "failed" && ( + dispatch({ type: "retry_resolution" })} + onBack={() => flowState.cancel()} + /> + )}
); }; @@ -147,6 +151,58 @@ function Idle({ handleSubmit }: { handleSubmit: Props["handleSubmit"] }) { ); } +const FAILURE_TEXT = { + no_factors: { + title: "error.title.hookFactorUnresolved", + subtitle: "error.subtitle.hookFactorUnresolved", + cta: "error.retry.hookFactorUnresolved", + }, + resolve_error: { + title: "error.title", + subtitle: "error.subtitle", + cta: "error.retry", + }, +} as const; + +function Failed({ + reason, + onRetry, + onBack, +}: { + reason: FailureReason; + onRetry: () => void; + onBack: () => void; +}) { + const { text } = useConfiguration(); + const { title, subtitle, cta } = FAILURE_TEXT[reason]; + + return ( +
+ +
+ + +
+ +
+ ); +} + function ResolvingFactors() { return ( <> diff --git a/packages/react/src/components/dynamic-flow/strict-mode.test.tsx b/packages/react/src/components/dynamic-flow/strict-mode.test.tsx new file mode 100644 index 00000000..0ca95799 --- /dev/null +++ b/packages/react/src/components/dynamic-flow/strict-mode.test.tsx @@ -0,0 +1,135 @@ +import { StrictMode } from "react"; +import { Errors, Factor, User } from "@slashid/slashid"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { DynamicFlow } from "."; +import { ConfigurationProvider } from "../../main"; +import { TestSlashIDProvider } from "../../context/test-providers"; +import { createTestUser, inputEmail, MockSlashID } from "../test-utils"; + +const hookUnresolved = () => + Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.hookFactorUnresolved, + message: "unresolved", + }); + +/** + * StrictMode double-invokes effects, which is where a synchronous submit can + * fire twice. getFactors is deliberately not asserted on: React calls that + * effect twice in development and the in-flight result is discarded. + */ +describe("under StrictMode", () => { + test("an unresolved SSO attempt still reaches the picker exactly once", async () => { + const logInMock = vi.fn( + (): Promise => Promise.reject(hookUnresolved()) + ); + const getFactors = vi.fn( + () => [{ method: "email_link" }, { method: "password" }] as Factor[] + ); + const user = userEvent.setup(); + + render( + + + + + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-dynamic-flow--resolved-factors") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(1); + expect(logInMock).toHaveBeenCalledWith( + { + factor: { method: "hook" }, + handle: { type: "email_address", value: "user@acme.test" }, + }, + { middleware: undefined } + ); + }); + + test("an empty getFactors result renders the no-method screen once", async () => { + const logInMock = vi.fn( + (): Promise => Promise.reject(hookUnresolved()) + ); + const user = userEvent.setup(); + + render( + + + + []} /> + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + const failed = await screen.findAllByTestId("sid-dynamic-flow--failed"); + expect(failed).toHaveLength(1); + }); + + test("a resolved SSO attempt renders the authenticating step", async () => { + const sid = new MockSlashID({ oid: "oid", analyticsEnabled: false }); + const logInMock = vi.fn((): Promise => { + sid.mockPublish("authnContextUpdateChallengeReceivedEvent", { + targetOrgId: "oid", + factor: { method: "email_link" }, + }); + return new Promise(() => {}); + }); + const user = userEvent.setup(); + + render( + + + + []} /> + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByText(/user@acme.test/) + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(1); + }); + + test("a plain login succeeds without a duplicate submit", async () => { + const testUser = createTestUser(); + const logInMock = vi.fn(() => Promise.resolve(testUser)); + const user = userEvent.setup(); + + render( + + + + [{ method: "email_link" }]} /> + + + + ); + + inputEmail("user@acme.test"); + await user.click(screen.getByTestId("sid-form-initial-submit-button")); + + await expect( + screen.findByTestId("sid-form-success-state") + ).resolves.toBeInTheDocument(); + expect(logInMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/components/form/authenticating/authenticating.test.tsx b/packages/react/src/components/form/authenticating/authenticating.test.tsx index 2e211ec1..143ab529 100644 --- a/packages/react/src/components/form/authenticating/authenticating.test.tsx +++ b/packages/react/src/components/form/authenticating/authenticating.test.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from "react"; import { render, screen } from "@testing-library/react"; import { AuthenticatingImplementation as Authenticating } from "./index"; import { AuthenticatingState } from "../flow/flow.common"; @@ -140,4 +141,144 @@ describe("Authenticating", () => { const subtitle = await screen.findByText("Authenticating subtitle"); expect(subtitle).toBeInTheDocument(); }); + describe("under StrictMode", () => { + const EVENT = "authnContextUpdateChallengeReceivedEvent" as const; + + function renderStrict(flowState: AuthenticatingState, sid: MockSlashID) { + return render( + + + + + + + + ); + } + + test("still renders the authenticating UI once the challenge arrives", async () => { + const flowState = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + }); + const sid = new MockSlashID({ oid: "oid", analyticsEnabled: false }); + + renderStrict(flowState, sid); + sid.mockPublish(EVENT, { + targetOrgId: "oid", + factor: { method: "email_link" }, + }); + + await expect( + screen.findByText(/test@mail.com/) + ).resolves.toBeInTheDocument(); + }); + + test("performs the login exactly once per attempt", () => { + const sid = new MockSlashID({ oid: "oid", analyticsEnabled: false }); + const first = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + }); + + const { rerender } = renderStrict(first, sid); + expect(first.logIn).toHaveBeenCalledTimes(1); + + const sameAttempt = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + }); + rerender( + + + + + + + + ); + expect(sameAttempt.logIn).not.toHaveBeenCalled(); + + const retry = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + attempt: 2, + }); + rerender( + + + + + + + + ); + expect(retry.logIn).toHaveBeenCalledTimes(1); + }); + }); + + describe("event subscriptions", () => { + const EVENT = "authnContextUpdateChallengeReceivedEvent" as const; + // the event buffer keeps one internal handler per event name, always + const BUFFERED = 1; + + test("releases the challenge subscription when it unmounts before the event", () => { + const flowState = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + }); + const mockSlashID = new MockSlashID({ + oid: "oid", + analyticsEnabled: false, + }); + + const { unmount } = render( + + + + + + ); + + expect(mockSlashID.mockObserverCount(EVENT)).toBe(BUFFERED + 1); + + unmount(); + + expect(mockSlashID.mockObserverCount(EVENT)).toBe(BUFFERED); + }); + + test("does not accumulate subscriptions across attempts", () => { + const mockSlashID = new MockSlashID({ + oid: "oid", + analyticsEnabled: false, + }); + const first = createTestAuhenticatingState({ + factor: { method: "hook" }, + handle: { type: "email_address", value: "test@mail.com" }, + }); + const second = createTestAuhenticatingState({ + factor: { method: "email_link" }, + handle: { type: "email_address", value: "test@mail.com" }, + attempt: 2, + }); + + const { rerender } = render( + + + + + + ); + + rerender( + + + + + + ); + + expect(mockSlashID.mockObserverCount(EVENT)).toBe(BUFFERED + 1); + }); + }); }); diff --git a/packages/react/src/components/form/authenticating/index.tsx b/packages/react/src/components/form/authenticating/index.tsx index 8627342f..19313b34 100644 --- a/packages/react/src/components/form/authenticating/index.tsx +++ b/packages/react/src/components/form/authenticating/index.tsx @@ -140,19 +140,15 @@ export const AuthenticatingImplementation = ({ flowState, }: AuthenticatingProps) => { const factor = flowState.context.config.factor; - const attempt = useRef(1); - const isLoggingIn = useRef(false); + // null rather than 0: the org-switching flow's first attempt is numbered 0 + const loggedInAttempt = useRef(null); const [establishedAuthContext, setEstablishedAuthContext] = useState(false); const { subscribe, unsubscribe } = useSlashID(); + // the subscription and the login have different lifecycles: every run must + // subscribe and every cleanup unsubscribe, while the login fires once per + // attempt and never again on a re-run useEffect(() => { - if (flowState.context.attempt > attempt.current) { - attempt.current = flowState.context.attempt; - isLoggingIn.current = false; - } - - if (isLoggingIn.current) return; - const handleAuthnContextUpdate = ( event: AuthnContextUpdateChallengeReceivedEvent ) => { @@ -164,11 +160,6 @@ export const AuthenticatingImplementation = ({ handle: flowState.context.config.handle, }, }); - - unsubscribe( - "authnContextUpdateChallengeReceivedEvent", - handleAuthnContextUpdate - ); setEstablishedAuthContext(true); }; @@ -177,9 +168,18 @@ export const AuthenticatingImplementation = ({ handleAuthnContextUpdate ); + return () => + unsubscribe( + "authnContextUpdateChallengeReceivedEvent", + handleAuthnContextUpdate + ); + }, [flowState, subscribe, unsubscribe]); + + useEffect(() => { + if (loggedInAttempt.current === flowState.context.attempt) return; + loggedInAttempt.current = flowState.context.attempt; flowState.logIn(); - isLoggingIn.current = true; - }, [flowState, flowState.context.attempt, subscribe, unsubscribe]); + }, [flowState]); if (!establishedAuthContext) { // block rendering until we hear back from the core SDK diff --git a/packages/react/src/components/form/flow/auth-flow.test.ts b/packages/react/src/components/form/flow/auth-flow.test.ts new file mode 100644 index 00000000..8095bf9e --- /dev/null +++ b/packages/react/src/components/form/flow/auth-flow.test.ts @@ -0,0 +1,258 @@ +import { Errors, User } from "@slashid/slashid"; +import { describe, expect, test, vi } from "vitest"; + +import { Handle } from "../../../domain/types"; +import { createAuthFlow, Flow } from "./auth-flow"; +import { + AuthenticatingState, + CreateFlowOptions, + ErrorState, + FlowState, + InitialState, +} from "./flow.common"; + +const HANDLE: Handle = { type: "email_address", value: "user@acme.test" }; + +const hookUnresolved = () => + Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.hookFactorUnresolved, + message: "unresolved", + }); + +/** + * The flow only performs a login once the SDK dependencies are set, and + * `logIn` never resolving keeps the flow in `authenticating` so tests can + * deliver their own outcome. + */ +function setup(opts: CreateFlowOptions = {}) { + const flow = createAuthFlow(opts); + const logIn = vi.fn(() => new Promise(() => {})); + + flow.setLogIn(logIn); + flow.setRecover(vi.fn()); + flow.setCancel(vi.fn()); + + let state: FlowState = flow.history[0].state; + flow.subscribe((next) => { + state = next; + }); + + return { + flow, + logIn, + get state() { + return state; + }, + }; +} + +function asInitial(state: FlowState): InitialState { + if (state.status !== "initial") { + throw new Error(`expected initial, got ${state.status}`); + } + return state; +} + +function asAuthenticating(state: FlowState): AuthenticatingState { + if (state.status !== "authenticating") { + throw new Error(`expected authenticating, got ${state.status}`); + } + return state; +} + +function asError(state: FlowState): ErrorState { + if (state.status !== "error") { + throw new Error(`expected error, got ${state.status}`); + } + return state; +} + +/** + * Drives the flow to `authenticating` and performs the login, which the + * authenticating state leaves to its consumer rather than doing on entry. + */ +function logInWith( + ctx: ReturnType, + factor: { method: string }, + handle: Handle | undefined = HANDLE +) { + asInitial(ctx.state).logIn({ + // @ts-expect-error tests drive the machine with arbitrary factor methods + factor, + handle, + }); + const authenticating = asAuthenticating(ctx.state); + authenticating.logIn(); + return authenticating; +} + +describe("createAuthFlow", () => { + test("starts in the initial state with no resumed handle", () => { + const { state } = setup(); + + expect(asInitial(state).resumedHandle).toBeUndefined(); + }); + + test("sid_login moves to authenticating with the given config", () => { + const ctx = setup(); + + const authenticating = logInWith(ctx, { method: "email_link" }); + + expect(authenticating.context.config).toEqual({ + factor: { method: "email_link" }, + handle: HANDLE, + }); + expect(authenticating.context.attempt).toBe(1); + }); + + describe("an unresolved SSO attempt", () => { + test("returns to initial carrying the handle instead of erroring", async () => { + const onError = vi.fn(); + const ctx = setup({ onError }); + const error = hookUnresolved(); + ctx.logIn.mockImplementation(() => Promise.reject(error)); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("initial")); + + expect(asInitial(ctx.state).resumedHandle).toEqual(HANDLE); + expect(onError).not.toHaveBeenCalled(); + }); + + test("is reported as an error when the factor is not hook", async () => { + const onError = vi.fn(); + const ctx = setup({ onError }); + const error = hookUnresolved(); + ctx.logIn.mockImplementation(() => Promise.reject(error)); + + logInWith(ctx, { method: "email_link" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + + expect(asError(ctx.state).context.error).toBe(error); + expect(onError).toHaveBeenCalledTimes(1); + }); + }); + + describe("any other failure of an SSO attempt", () => { + test.each([ + ["a plain error", new Error("network down")], + [ + "an API response error", + Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.rateLimitError, + message: "slow down", + }), + ], + ])("is reported as an error: %s", async (_label, error) => { + const onError = vi.fn(); + const ctx = setup({ onError }); + ctx.logIn.mockImplementation(() => Promise.reject(error)); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + + expect(asError(ctx.state).context.error).toBe(error); + expect(asError(ctx.state).context.config.factor).toEqual({ + method: "hook", + }); + expect(onError).toHaveBeenCalledTimes(1); + }); + }); + + test("retrying a hook context keeps the hook factor and bumps the attempt", async () => { + const ctx = setup(); + ctx.logIn.mockImplementation(() => Promise.reject(new Error("network"))); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + + ctx.logIn.mockImplementation(() => new Promise(() => {})); + asError(ctx.state).retry("retry"); + + const authenticating = asAuthenticating(ctx.state); + authenticating.logIn(); + expect(authenticating.context.config.factor).toEqual({ method: "hook" }); + expect(authenticating.context.attempt).toBe(2); + }); + + test("retry then unresolved still returns to initial with the handle", async () => { + const onError = vi.fn(); + const ctx = setup({ onError }); + ctx.logIn.mockImplementation(() => Promise.reject(new Error("network"))); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + + ctx.logIn.mockImplementation(() => Promise.reject(hookUnresolved())); + asError(ctx.state).retry("retry"); + asAuthenticating(ctx.state).logIn(); + + await vi.waitFor(() => expect(ctx.state.status).toBe("initial")); + expect(asInitial(ctx.state).resumedHandle).toEqual(HANDLE); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test("retrying with the reset policy returns to initial with no handle", async () => { + const ctx = setup(); + ctx.logIn.mockImplementation(() => Promise.reject(new Error("nope"))); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + + asError(ctx.state).retry("reset"); + + expect(asInitial(ctx.state).resumedHandle).toBeUndefined(); + }); + + test("cancelling from a resumed initial state clears the handle", async () => { + const ctx = setup(); + ctx.logIn.mockImplementation(() => Promise.reject(hookUnresolved())); + + logInWith(ctx, { method: "hook" }); + await vi.waitFor(() => expect(ctx.state.status).toBe("initial")); + expect(asInitial(ctx.state).resumedHandle).toEqual(HANDLE); + + asInitial(ctx.state).cancel(); + + expect(asInitial(ctx.state).resumedHandle).toBeUndefined(); + }); + + describe("observers", () => { + const transition = (flow: Flow) => { + const state = flow.history[flow.history.length - 1].state; + if (state.status === "initial") { + state.logIn({ factor: { method: "email_link" }, handle: HANDLE }); + } + }; + + test("unsubscribing removes only the given observer", () => { + const flow = createAuthFlow(); + flow.setLogIn(vi.fn(() => new Promise(() => {}))); + flow.setRecover(vi.fn()); + const kept = vi.fn(); + const removed = vi.fn(); + + flow.subscribe(kept); + flow.subscribe(removed); + flow.unsubscribe(removed); + transition(flow); + + expect(kept).toHaveBeenCalledTimes(1); + expect(removed).not.toHaveBeenCalled(); + }); + + test("resubscribing after unsubscribing notifies once per transition", () => { + const flow = createAuthFlow(); + flow.setLogIn(vi.fn(() => new Promise(() => {}))); + flow.setRecover(vi.fn()); + const observer = vi.fn(); + + flow.subscribe(observer); + flow.unsubscribe(observer); + flow.subscribe(observer); + transition(flow); + + expect(observer).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react/src/components/form/flow/auth-flow.ts b/packages/react/src/components/form/flow/auth-flow.ts index 757bd76d..044d4e6d 100644 --- a/packages/react/src/components/form/flow/auth-flow.ts +++ b/packages/react/src/components/form/flow/auth-flow.ts @@ -1,4 +1,6 @@ +import { Errors } from "@slashid/slashid"; import { Cancel, LogIn, MFA, Recover } from "../../../domain/types"; +import { isFactorHook } from "../../../domain/handles"; import { CreateFlowOptions, Observer, @@ -8,6 +10,7 @@ import { createTransitionFunction, FlowState, FlowHandlers, + TransitionHandler, loginHandler, storeRecoveryCodesHandler, loginUpdateContextHandler, @@ -16,12 +19,36 @@ import { retryHandler, } from "./flow.common"; +/** + * An SSO attempt that resolves no factor is not a failed login - it is a + * request to go back and pick a factor for the same handle. The hook factor + * check must come first: `isHookFactorUnresolvedError` does not exist below + * core SDK 3.30.0, and only 3.30.0 can submit a hook factor. + */ +const authLoginErrorHandler: TransitionHandler = (event, state, deps) => { + if (state.status !== "authenticating") return; + const e = event as Event & { type: "sid_login.error" }; + + if ( + isFactorHook(state.context.config.factor) && + Errors.isHookFactorUnresolvedError(e.error) + ) { + deps.setState( + createInitialState(deps.send, state.context.config.handle), + event + ); + return; + } + + loginErrorHandler(event, state, deps); +}; + const authFlowHandlers: FlowHandlers = { sid_login: loginHandler, sid_storeRecoveryCodes: storeRecoveryCodesHandler, "sid_login.update_context": loginUpdateContextHandler, "sid_login.success": loginSuccessHandler, - "sid_login.error": loginErrorHandler, + "sid_login.error": authLoginErrorHandler, sid_retry: retryHandler, sid_cancel: (event, state, deps) => { const cancelFn = deps.getCancelFn(); @@ -95,7 +122,7 @@ export function createAuthFlow(opts: CreateFlowOptions = {}) { return { history, unsubscribe: (observer: Observer) => { - observers = observers.filter((ob) => ob === observer); + observers = observers.filter((ob) => ob !== observer); }, subscribe: (observer: Observer) => { observers.push(observer); diff --git a/packages/react/src/components/form/flow/flow.common.ts b/packages/react/src/components/form/flow/flow.common.ts index 6226c748..184475a1 100644 --- a/packages/react/src/components/form/flow/flow.common.ts +++ b/packages/react/src/components/form/flow/flow.common.ts @@ -24,6 +24,11 @@ export interface InitialState { status: "initial"; logIn: (config: LoginConfiguration, options?: LoginOptions) => void; cancel: Cancel; + /** + * Set when an SSO attempt for this handle resolved no factor, so the + * identifier step resumes at factor resolution instead of asking again. + */ + resumedHandle?: Handle; } export interface AuthenticatingState { @@ -116,7 +121,10 @@ export type FlowState = FlowActions & export type Observer = (state: FlowState, event: Event) => void; export type Send = (e: Event) => void; -export const createInitialState = (send: Send): InitialState => { +export const createInitialState = ( + send: Send, + resumedHandle?: Handle +): InitialState => { return { status: "initial", logIn: (config, options) => { @@ -125,6 +133,7 @@ export const createInitialState = (send: Send): InitialState => { cancel: () => { send({ type: "sid_cancel" }); }, + resumedHandle, }; }; @@ -295,7 +304,7 @@ type StaticDependencies = { type FlowDependencies = AsyncDependencies & StaticDependencies; -type TransitionHandler = ( +export type TransitionHandler = ( event: Event, state: FlowState, deps: FlowDependencies diff --git a/packages/react/src/components/form/flow/org-switching-flow.test.ts b/packages/react/src/components/form/flow/org-switching-flow.test.ts new file mode 100644 index 00000000..656404df --- /dev/null +++ b/packages/react/src/components/form/flow/org-switching-flow.test.ts @@ -0,0 +1,75 @@ +import { Errors, User } from "@slashid/slashid"; +import { describe, expect, test, vi } from "vitest"; + +import { Handle } from "../../../domain/types"; +import { createOrgSwitchingFlow } from "./org-switching-flow"; +import { FlowState } from "./flow.common"; + +const HANDLE: Handle = { type: "email_address", value: "user@acme.test" }; + +/** + * The flow builds its authenticating state on construction, capturing the + * login function, so it has to arrive through the options rather than setLogIn. + */ +function setup(opts: Parameters[0] = {}) { + const logIn = vi.fn(() => new Promise(() => {})); + const flow = createOrgSwitchingFlow({ + lastUserHandle: HANDLE, + logInFn: logIn, + recover: vi.fn(), + ...opts, + }); + + let state: FlowState = flow.history[0].state; + flow.subscribe((next) => { + state = next; + }); + + return { + flow, + logIn, + get state() { + return state; + }, + }; +} + +describe("createOrgSwitchingFlow", () => { + test("reports an unresolved hook factor as an error", async () => { + const onError = vi.fn(); + const ctx = setup({ onError }); + const error = Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.hookFactorUnresolved, + message: "unresolved", + }); + ctx.logIn.mockImplementation(() => Promise.reject(error)); + + const initial = ctx.state; + if (initial.status !== "authenticating") { + throw new Error(`expected authenticating, got ${initial.status}`); + } + initial.logIn(); + + await vi.waitFor(() => expect(ctx.state.status).toBe("error")); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test("unsubscribing removes only the given observer", () => { + const ctx = setup(); + const kept = vi.fn(); + const removed = vi.fn(); + + ctx.flow.subscribe(kept); + ctx.flow.subscribe(removed); + ctx.flow.unsubscribe(removed); + + const state = ctx.state; + if (state.status !== "authenticating") { + throw new Error(`expected authenticating, got ${state.status}`); + } + state.updateContext(state.context); + + expect(kept).toHaveBeenCalledTimes(1); + expect(removed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/form/flow/org-switching-flow.ts b/packages/react/src/components/form/flow/org-switching-flow.ts index 8c503bec..be635540 100644 --- a/packages/react/src/components/form/flow/org-switching-flow.ts +++ b/packages/react/src/components/form/flow/org-switching-flow.ts @@ -110,7 +110,7 @@ export function createOrgSwitchingFlow(opts: CreateFlowOptions) { return { history, unsubscribe: (observer: Observer) => { - observers = observers.filter((ob) => ob === observer); + observers = observers.filter((ob) => ob !== observer); }, subscribe: (observer: Observer) => { observers.push(observer); diff --git a/packages/react/src/components/test-utils.ts b/packages/react/src/components/test-utils.ts index db2aa23c..0af85378 100644 --- a/packages/react/src/components/test-utils.ts +++ b/packages/react/src/components/test-utils.ts @@ -222,6 +222,12 @@ export class MockSlashID extends SlashID { } } + public mockObserverCount( + type: Key + ): number { + return this.observers.get(type)?.length ?? 0; + } + public mockPublish( type: Key, payload: PublicReadEvents[Key] diff --git a/packages/react/src/domain/handles.test.ts b/packages/react/src/domain/handles.test.ts index 688afb03..3ed9b9b9 100644 --- a/packages/react/src/domain/handles.test.ts +++ b/packages/react/src/domain/handles.test.ts @@ -5,7 +5,6 @@ import { isFactorHook, parsePhoneNumber, ParsedPhoneNumber, - shouldAttemptSSO, } from "./handles"; const phoneNumbersTestData: { @@ -130,16 +129,4 @@ describe("hook factor", () => { ).toEqual([{ method: "email_link" }]); }); - test("shouldAttemptSSO only for email handles that were not resumed", () => { - const email = { type: "email_address" as const, value: "user@acme.test" }; - const phone = { type: "phone_number" as const, value: "+15550000000" }; - - expect(shouldAttemptSSO(email, true, undefined)).toBe(true); - expect(shouldAttemptSSO(email, false, undefined)).toBe(false); - expect(shouldAttemptSSO(email, undefined, undefined)).toBe(false); - expect(shouldAttemptSSO(phone, true, undefined)).toBe(false); - expect(shouldAttemptSSO(undefined, true, undefined)).toBe(false); - expect(shouldAttemptSSO(email, true, email)).toBe(false); - expect(shouldAttemptSSO(email, true, { ...email })).toBe(true); - }); }); diff --git a/packages/react/src/domain/handles.ts b/packages/react/src/domain/handles.ts index c5c9505a..11e510fb 100644 --- a/packages/react/src/domain/handles.ts +++ b/packages/react/src/domain/handles.ts @@ -167,19 +167,6 @@ export function isFactorHook(factor: Factor): factor is FactorHook { return factor.method === "hook"; } -export function shouldAttemptSSO( - handle: Handle | undefined, - attemptSSO: boolean | undefined, - resumedHandle: Handle | undefined -): handle is Handle { - return ( - !!attemptSSO && - !!handle && - handle.type === "email_address" && - handle !== resumedHandle - ); -} - export function hasOidcAndNonOidcFactors(factors: Factor[]): boolean { return factors.some(isFactorOidc) && factors.some((f) => !isFactorOidc(f)); }