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', }