0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat: support direct sign-in for sso (#5589)

This commit is contained in:
Gao Sun 2024-03-29 21:56:25 +08:00 committed by GitHub
parent 6d56434b48
commit 7756f50f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 108 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/experience": minor
---
support direct sign-in for sso

View file

@ -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(<DirectSignIn />);
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(<DirectSignIn />);
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(<DirectSignIn />);
expect(replace).not.toBeCalled();
expect(assign).toBeCalledWith('/sso-redirect-to');
});
});

View file

@ -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 <LoadingLayer />;
};

View file

@ -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);

View file

@ -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) {

View file

@ -31,8 +31,8 @@ export enum ExtraParamsKey {
* @remark
* The format of the value for this key is one of the following:
*
* - `<method>` (e.g. `email`, `sms`)
* - `social:<target>` (e.g. `social:google`, `social:facebook`)
* - `social:<target>` (Use a social connector with the specified target, e.g. `social:google`)
* - `sso:<connector-id>` (Use the specified SSO connector, e.g. `sso:123456`)
*/
DirectSignIn = 'direct_sign_in',
}