Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/one-step-sso-hook.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion packages/demo-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<DynamicFlow attemptSSO getFactors={() => [{ 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.
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
246 changes: 245 additions & 1 deletion packages/react/src/components/dynamic-flow/dynamic-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<User | undefined>(() => {}));
const getFactors = vi.fn(() => [{ method: "email_link" }] as Factor[]);
const user = userEvent.setup();

render(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow attemptSSO getFactors={getFactors} />
</ConfigurationProvider>
</TestSlashIDProvider>
);

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<User | undefined> => Promise.reject(hookUnresolved())
);
const getFactors = vi.fn(
() => [{ method: "email_link" }, { method: "password" }] as Factor[]
);
const onError = vi.fn();
const user = userEvent.setup();

render(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow attemptSSO onError={onError} getFactors={getFactors} />
</ConfigurationProvider>
</TestSlashIDProvider>
);

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(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow
attemptSSO
getFactors={() => [{ method: "email_link" }, { method: "password" }]}
/>
</ConfigurationProvider>
</TestSlashIDProvider>
);

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(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow
attemptSSO
onSuccess={onSuccess}
getFactors={() => [{ method: "email_link" }]}
/>
</ConfigurationProvider>
</TestSlashIDProvider>
);

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(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow
attemptSSO
onError={onError}
getFactors={() => [{ method: "email_link" }]}
/>
</ConfigurationProvider>
</TestSlashIDProvider>
);

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(
<TestSlashIDProvider sdkState="ready" logIn={logInMock} sid={sid}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow
attemptSSO
onSuccess={onSuccess}
getFactors={() => [{ method: "email_link" }]}
/>
</ConfigurationProvider>
</TestSlashIDProvider>
);

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(
<TestSlashIDProvider sdkState="ready" logIn={logInMock}>
<ConfigurationProvider factors={[{ method: "email_link" }]}>
<DynamicFlow attemptSSO onError={onError} getFactors={getFactors} />
</ConfigurationProvider>
</TestSlashIDProvider>
);

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);
});
});
1 change: 1 addition & 0 deletions packages/react/src/components/dynamic-flow/handle-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const FACTOR_LABEL_MAP: Record<
oidc: "",
saml: "",
totp: "",
hook: "",
};

export type Props = {
Expand Down
Loading
Loading