From be811ac074f398e6440709df1c9e4cc75c70b8dc Mon Sep 17 00:00:00 2001 From: ikovic Date: Wed, 9 Sep 2026 19:03:14 +0200 Subject: [PATCH 1/2] 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/2] 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==}