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:
parent
6d56434b48
commit
7756f50f8e
6 changed files with 108 additions and 11 deletions
5
.changeset/great-peaches-work.md
Normal file
5
.changeset/great-peaches-work.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/experience": minor
|
||||
---
|
||||
|
||||
support direct sign-in for sso
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue