Skip to content

Commit

Permalink
feat: support direct sign-in for sso (#5589)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Mar 29, 2024
1 parent 6d56434 commit 7756f50
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/great-peaches-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/experience": minor
---

support direct sign-in for sso
39 changes: 35 additions & 4 deletions packages/experience/src/pages/DirectSignIn/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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({}),
Expand Down Expand Up @@ -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`);

Expand All @@ -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');
});
});
17 changes: 15 additions & 2 deletions packages/experience/src/pages/DirectSignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 />;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,44 @@
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.
*/
// 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: {
Expand All @@ -29,6 +51,7 @@ describe('direct sign-in', () => {
},
],
},
singleSignOnEnabled: true,
socialSignInConnectorTargets: ['mock-social'],
});
});
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/schemas/src/consts/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down

0 comments on commit 7756f50

Please sign in to comment.