diff --git a/.changeset/great-peaches-work.md b/.changeset/great-peaches-work.md
new file mode 100644
index 000000000..04aeb6d78
--- /dev/null
+++ b/.changeset/great-peaches-work.md
@@ -0,0 +1,5 @@
+---
+"@logto/experience": minor
+---
+
+support direct sign-in for sso
diff --git a/packages/experience/src/pages/DirectSignIn/index.test.tsx b/packages/experience/src/pages/DirectSignIn/index.test.tsx
index c08f3bb40..566618a50 100644
--- a/packages/experience/src/pages/DirectSignIn/index.test.tsx
+++ b/packages/experience/src/pages/DirectSignIn/index.test.tsx
@@ -1,19 +1,33 @@
import { useParams as useParamsMock } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { socialConnectors } from '@/__mocks__/logto';
+import { mockSsoConnectors, socialConnectors } from '@/__mocks__/logto';
import DirectSignIn from '.';
+jest.mock('@/hooks/use-sie', () => ({
+ useSieMethods: jest.fn().mockReturnValue({
+ socialConnectors,
+ ssoConnectors: mockSsoConnectors,
+ }),
+}));
+
jest.mock('@/containers/SocialSignInList/use-social', () =>
jest.fn().mockReturnValue({
- socialConnectors,
invokeSocialSignIn: jest.fn(() => {
window.location.assign('/social-redirect-to');
}),
})
);
+jest.mock('@/hooks/use-single-sign-on', () =>
+ jest.fn().mockReturnValue(
+ jest.fn(() => {
+ window.location.assign('/sso-redirect-to');
+ })
+ )
+);
+
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockReturnValue({}),
@@ -62,14 +76,21 @@ describe('DirectSignIn', () => {
expect(replace).toBeCalledWith('/register');
});
- it('should fallback to the first screen when method is valid but target is invalid', () => {
+ it('should fallback to the first screen when method is valid but target is invalid (social)', () => {
useParams.mockReturnValue({ method: 'social', target: 'something' });
search.mockReturnValue('?fallback=sign-in');
renderWithPageContext();
expect(replace).toBeCalledWith('/sign-in');
});
- it('should invoke social sign-in when method is social and target is valid', () => {
+ it('should fallback to the first screen when method is valid but target is invalid (sso)', () => {
+ useParams.mockReturnValue({ method: 'sso', target: 'something' });
+ search.mockReturnValue('?fallback=sign-in');
+ renderWithPageContext();
+ expect(replace).toBeCalledWith('/sign-in');
+ });
+
+ it('should invoke social sign-in when method is social and target is valid (social)', () => {
useParams.mockReturnValue({ method: 'social', target: socialConnectors[0]!.target });
search.mockReturnValue(`?fallback=sign-in`);
@@ -78,4 +99,14 @@ describe('DirectSignIn', () => {
expect(replace).not.toBeCalled();
expect(assign).toBeCalledWith('/social-redirect-to');
});
+
+ it('should invoke sso sign-in when method is sso and target is valid (sso)', () => {
+ useParams.mockReturnValue({ method: 'sso', target: mockSsoConnectors[0]!.id });
+ search.mockReturnValue(`?fallback=sign-in`);
+
+ renderWithPageContext();
+
+ expect(replace).not.toBeCalled();
+ expect(assign).toBeCalledWith('/sso-redirect-to');
+ });
});
diff --git a/packages/experience/src/pages/DirectSignIn/index.tsx b/packages/experience/src/pages/DirectSignIn/index.tsx
index 43d985a89..9665fc4d1 100644
--- a/packages/experience/src/pages/DirectSignIn/index.tsx
+++ b/packages/experience/src/pages/DirectSignIn/index.tsx
@@ -4,10 +4,14 @@ import { useParams } from 'react-router-dom';
import LoadingLayer from '@/components/LoadingLayer';
import useSocial from '@/containers/SocialSignInList/use-social';
+import { useSieMethods } from '@/hooks/use-sie';
+import useSingleSignOn from '@/hooks/use-single-sign-on';
const DirectSignIn = () => {
const { method, target } = useParams();
- const { socialConnectors, invokeSocialSignIn } = useSocial();
+ const { socialConnectors, ssoConnectors } = useSieMethods();
+ const { invokeSocialSignIn } = useSocial();
+ const invokeSso = useSingleSignOn();
const fallback = useMemo(() => {
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
return (
@@ -25,8 +29,17 @@ const DirectSignIn = () => {
}
}
+ if (method === 'sso') {
+ const sso = ssoConnectors.find((connector) => connector.id === target);
+
+ if (sso) {
+ void invokeSso(sso.id);
+ return;
+ }
+ }
+
window.location.replace('/' + fallback);
- }, [fallback, invokeSocialSignIn, method, socialConnectors, target]);
+ }, [fallback, invokeSocialSignIn, invokeSso, method, socialConnectors, ssoConnectors, target]);
return ;
};
diff --git a/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts b/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts
index 9c12c1ca1..aa19dfff1 100644
--- a/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts
+++ b/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts
@@ -1,12 +1,17 @@
+import crypto from 'node:crypto';
+
import { ConnectorType } from '@logto/connector-kit';
-import { SignInIdentifier } from '@logto/schemas';
+import { SignInIdentifier, SsoProviderName } from '@logto/schemas';
import { mockSocialConnectorTarget } from '#src/__mocks__/connectors-mock.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
-import { demoAppUrl } from '#src/constants.js';
+import { createSsoConnector } from '#src/api/sso-connector.js';
+import { demoAppUrl, logtoUrl } from '#src/constants.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
+const randomString = () => crypto.randomBytes(8).toString('hex');
+
/**
* NOTE: This test suite assumes test cases will run sequentially (which is Jest default).
* Parallel execution will lead to errors.
@@ -14,9 +19,26 @@ import ExpectExperience from '#src/ui-helpers/expect-experience.js';
// Tip: See https://github.com/argos-ci/jest-puppeteer/blob/main/packages/expect-puppeteer/README.md
// for convenient expect methods
describe('direct sign-in', () => {
+ const context = new (class Context {
+ ssoConnectorId?: string;
+ })();
+ const ssoOidcIssuer = `${logtoUrl}/oidc`;
+
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
await setSocialConnector();
+ const ssoConnector = await createSsoConnector({
+ providerName: SsoProviderName.OIDC,
+ connectorName: 'test-oidc-' + randomString(),
+ domains: [`foo${randomString()}.com`],
+ config: {
+ clientId: 'foo',
+ clientSecret: 'bar',
+ issuer: ssoOidcIssuer,
+ },
+ });
+ // eslint-disable-next-line @silverhand/fp/no-mutation
+ context.ssoConnectorId = ssoConnector.id;
await updateSignInExperience({
signUp: { identifiers: [], password: true, verify: false },
signIn: {
@@ -29,6 +51,7 @@ describe('direct sign-in', () => {
},
],
},
+ singleSignOnEnabled: true,
socialSignInConnectorTargets: ['mock-social'],
});
});
@@ -45,6 +68,28 @@ describe('direct sign-in', () => {
await experience.page.close();
});
+ it('should be landed to the sso identity provider directly', async () => {
+ const experience = new ExpectExperience(await browser.newPage());
+ const url = new URL(demoAppUrl);
+
+ url.searchParams.set('direct_sign_in', `sso:${context.ssoConnectorId!}`);
+ await experience.page.goto(url.href);
+ await experience.toProcessSocialSignIn({
+ socialUserId: 'foo',
+ clickButton: false,
+ authUrl: ssoOidcIssuer + '/auth',
+ });
+
+ // The SSO sign-in flow won't succeed, but the user should be redirected back to the demo app
+ // with the code and user ID in the query string.
+ const callbackUrl = new URL(experience.page.url());
+ expect(callbackUrl.searchParams.get('code')).toBe('mock-code');
+ expect(callbackUrl.searchParams.get('userId')).toBe('foo');
+ expect(new URL(callbackUrl.pathname, callbackUrl.origin).href).toBe(demoAppUrl.href);
+
+ await experience.page.close();
+ });
+
it('should fall back to the sign-in page if the direct sign-in target is invalid', async () => {
const experience = new ExpectExperience(await browser.newPage());
const url = new URL(demoAppUrl);
diff --git a/packages/integration-tests/src/ui-helpers/expect-experience.ts b/packages/integration-tests/src/ui-helpers/expect-experience.ts
index f2f637269..ba5410167 100644
--- a/packages/integration-tests/src/ui-helpers/expect-experience.ts
+++ b/packages/integration-tests/src/ui-helpers/expect-experience.ts
@@ -248,15 +248,18 @@ export default class ExpectExperience extends ExpectPage {
socialEmail,
socialPhone,
clickButton = true,
+ authUrl = mockSocialAuthPageUrl,
}: {
socialUserId: string;
socialEmail?: string;
socialPhone?: string;
/** Whether to click the "Continue with [social name]" button on the page. */
clickButton?: boolean;
+ /** The URL to wait for the social auth page. */
+ authUrl?: string;
}) {
const authPageRequestListener = this.page.waitForRequest((request) =>
- request.url().startsWith(mockSocialAuthPageUrl)
+ request.url().startsWith(authUrl)
);
if (clickButton) {
diff --git a/packages/schemas/src/consts/oidc.ts b/packages/schemas/src/consts/oidc.ts
index 865ed689d..162280b5c 100644
--- a/packages/schemas/src/consts/oidc.ts
+++ b/packages/schemas/src/consts/oidc.ts
@@ -31,8 +31,8 @@ export enum ExtraParamsKey {
* @remark
* The format of the value for this key is one of the following:
*
- * - `` (e.g. `email`, `sms`)
- * - `social:` (e.g. `social:google`, `social:facebook`)
+ * - `social:` (Use a social connector with the specified target, e.g. `social:google`)
+ * - `sso:` (Use the specified SSO connector, e.g. `sso:123456`)
*/
DirectSignIn = 'direct_sign_in',
}