From 2cbc591ff68d4e4e6e61950686dd0d9c9f1e9e3d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 26 Mar 2024 13:23:41 +0800 Subject: [PATCH] feat: support direct sign-in (#5536) * feat: support direct sign-in * chore: add changesets * refactor: add test cases * chore(deps): upgrade logto sdks --- .changeset/hip-ladybugs-fry.md | 27 +++++++ .changeset/nasty-beds-flash.md | 9 +++ .changeset/rude-radios-clean.md | 15 ++++ .changeset/smart-walls-occur.md | 7 ++ packages/console/src/pages/Callback/index.tsx | 2 - .../core/src/libraries/verification-status.ts | 3 +- packages/core/src/oidc/init.ts | 27 +++---- packages/core/src/oidc/type.ts | 8 -- packages/core/src/oidc/utils.test.ts | 56 ++++++++++++- packages/core/src/oidc/utils.ts | 38 ++++++++- packages/core/src/routes/consts.ts | 11 --- packages/core/src/tenants/Tenant.ts | 5 +- packages/demo-app/src/App.tsx | 5 +- packages/experience/src/App.tsx | 53 ++++++------ .../SettingsProvider.tsx | 7 +- packages/experience/src/constants/env.ts | 2 - .../src/hooks/use-check-single-sign-on.ts | 5 +- .../src/hooks/use-single-sign-on-watch.ts | 5 +- .../experience/src/pages/Callback/index.tsx | 2 +- .../Callback/use-social-callback-handler.ts | 2 +- .../src/pages/DirectSignIn/index.test.tsx | 81 +++++++++++++++++++ .../src/pages/DirectSignIn/index.tsx | 33 ++++++++ .../IdentifierRegisterForm/index.test.tsx | 5 +- .../IdentifierSignInForm/index.test.tsx | 5 +- .../SignIn/PasswordSignInForm/index.test.tsx | 5 +- .../SingleSignOn.tsx | 0 .../SocialSignIn.tsx | 4 +- .../index.test.tsx | 0 .../index.tsx | 12 +-- .../use-single-sign-on-listener.ts | 0 .../use-social-sign-in-listener.ts | 0 .../integration-tests/src/client/index.ts | 12 ++- .../integration-tests/src/helpers/client.ts | 10 ++- .../happy-path.test.ts | 37 ++++++++- .../sad-path.test.ts | 0 .../tests/experience/direct-sign-in.test.ts | 68 ++++++++++++++++ .../tests/experience/password-policy.test.ts | 21 ++--- .../src/ui-helpers/expect-experience.ts | 39 ++++++--- packages/schemas/src/consts/experience.ts | 10 +++ packages/schemas/src/consts/index.ts | 1 + packages/schemas/src/consts/oidc.ts | 48 +++++++++++ packages/toolkit/core-kit/package.json | 2 +- .../toolkit/core-kit/src/password-policy.ts | 6 ++ .../core-kit/src/utils/integration-test.ts | 10 +++ pnpm-lock.yaml | 6 +- 45 files changed, 573 insertions(+), 131 deletions(-) create mode 100644 .changeset/hip-ladybugs-fry.md create mode 100644 .changeset/nasty-beds-flash.md create mode 100644 .changeset/rude-radios-clean.md create mode 100644 .changeset/smart-walls-occur.md delete mode 100644 packages/core/src/oidc/type.ts delete mode 100644 packages/core/src/routes/consts.ts create mode 100644 packages/experience/src/pages/DirectSignIn/index.test.tsx create mode 100644 packages/experience/src/pages/DirectSignIn/index.tsx rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/SingleSignOn.tsx (100%) rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/SocialSignIn.tsx (77%) rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/index.test.tsx (100%) rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/index.tsx (65%) rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/use-single-sign-on-listener.ts (100%) rename packages/experience/src/pages/{SocialSignInCallback => SocialSignInWebCallback}/use-social-sign-in-listener.ts (100%) rename packages/integration-tests/src/tests/api/interaction/{social-interaction => social-sign-in}/happy-path.test.ts (90%) rename packages/integration-tests/src/tests/api/interaction/{social-interaction => social-sign-in}/sad-path.test.ts (100%) create mode 100644 packages/integration-tests/src/tests/experience/direct-sign-in.test.ts create mode 100644 packages/schemas/src/consts/experience.ts create mode 100644 packages/toolkit/core-kit/src/utils/integration-test.ts diff --git a/.changeset/hip-ladybugs-fry.md b/.changeset/hip-ladybugs-fry.md new file mode 100644 index 000000000..1940d2ee9 --- /dev/null +++ b/.changeset/hip-ladybugs-fry.md @@ -0,0 +1,27 @@ +--- +"@logto/core": minor +--- + +support `first_screen` parameter in authentication request + +Sign-in experience can be initiated with a specific screen by setting the `first_screen` parameter in the OIDC authentication request. This parameter is intended to replace the `interaction_mode` parameter, which is now deprecated. + +The `first_screen` parameter can have the following values: + +- `signIn`: The sign-in screen is displayed first. +- `register`: The registration screen is displayed first. + +Here's a non-normative example of how to use the `first_screen` parameter: + +``` +GET /authorize? + response_type=code + &client_id=your_client_id + &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + &scope=openid + &state=af0ifjsldkj + &nonce=n-0S6_WzA2Mj + &first_screen=signIn +``` + +When `first_screen` is set, the legacy `interaction_mode` parameter is ignored. diff --git a/.changeset/nasty-beds-flash.md b/.changeset/nasty-beds-flash.md new file mode 100644 index 000000000..e099761c7 --- /dev/null +++ b/.changeset/nasty-beds-flash.md @@ -0,0 +1,9 @@ +--- +"@logto/schemas": minor +--- + +add oidc params variables and types + +- Add `ExtraParamsKey` enum for all possible OIDC extra parameters that Logto supports. +- Add `FirstScreen` enum for the `first_screen` parameter. +- Add `extraParamsObjectGuard` guard and `ExtraParamsObject` type for shaping the extra parameters object in the OIDC authentication request. diff --git a/.changeset/rude-radios-clean.md b/.changeset/rude-radios-clean.md new file mode 100644 index 000000000..413a01c9e --- /dev/null +++ b/.changeset/rude-radios-clean.md @@ -0,0 +1,15 @@ +--- +"@logto/experience": minor +"@logto/core": minor +--- + +support direct sign-in + +Instead of showing a screen for the user to choose between the sign-in methods, a specific sign-in method can be initiated directly by setting the `direct_sign_in` parameter in the OIDC authentication request. + +This parameter follows the format of `direct_sign_in=:`, where: + +- `` is the sign-in method to trigger. Currently the only supported value is `social`. +- `` is the target value for the sign-in method. If the method is `social`, the value is the social connector's `target`. + +When a valid `direct_sign_in` parameter is set, the first screen will be skipped and the specified sign-in method will be triggered immediately upon entering the sign-in experience. If the parameter is invalid, the default behavior of showing the first screen will be used. diff --git a/.changeset/smart-walls-occur.md b/.changeset/smart-walls-occur.md new file mode 100644 index 000000000..559996d0b --- /dev/null +++ b/.changeset/smart-walls-occur.md @@ -0,0 +1,7 @@ +--- +"@logto/demo-app": minor +--- + +carry over search params to the authentication request + +When entering the Logto demo app with search parameters, if the user is not authenticated, the search parameters are now carried over to the authentication request. This allows manual testing of the OIDC authentication flow with specific parameters. diff --git a/packages/console/src/pages/Callback/index.tsx b/packages/console/src/pages/Callback/index.tsx index b82eb8f5a..ac1e2cffb 100644 --- a/packages/console/src/pages/Callback/index.tsx +++ b/packages/console/src/pages/Callback/index.tsx @@ -2,12 +2,10 @@ import { useHandleSignInCallback } from '@logto/react'; import { useNavigate } from 'react-router-dom'; import AppLoading from '@/components/AppLoading'; -import useTenantPathname from '@/hooks/use-tenant-pathname'; import { consumeSavedRedirect } from '@/utils/storage'; /** The global callback page for all sign-in redirects from Logto main flow. */ function Callback() { - const { getTo } = useTenantPathname(); const navigate = useNavigate(); useHandleSignInCallback(() => { diff --git a/packages/core/src/libraries/verification-status.ts b/packages/core/src/libraries/verification-status.ts index d51979b28..bdf1565b7 100644 --- a/packages/core/src/libraries/verification-status.ts +++ b/packages/core/src/libraries/verification-status.ts @@ -1,10 +1,11 @@ import { generateStandardId } from '@logto/shared'; import RequestError from '#src/errors/RequestError/index.js'; -import { verificationTimeout } from '#src/routes/consts.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; +const verificationTimeout = 10 * 60 * 1000; // 10 mins + export const createVerificationStatusLibrary = (queries: Queries) => { const { findVerificationStatusByUserId, diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 1515abb96..8f47acb45 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -8,11 +8,13 @@ import type { I18nKey } from '@logto/phrases'; import { customClientMetadataDefault, CustomClientMetadataKey, - demoAppApplicationId, + experience, + extraParamsObjectGuard, inSeconds, logtoCookieKey, type LogtoUiCookie, LogtoJwtTokenKey, + ExtraParamsKey, } from '@logto/schemas'; import { conditional, trySafe, tryThat } from '@silverhand/essentials'; import i18next from 'i18next'; @@ -28,8 +30,11 @@ import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaBodyEtag from '#src/middleware/koa-body-etag.js'; import postgresAdapter from '#src/oidc/adapter.js'; -import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js'; -import { routes } from '#src/routes/consts.js'; +import { + buildLoginPromptUrl, + isOriginAllowed, + validateCustomClientMetadata, +} from '#src/oidc/utils.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; @@ -43,7 +48,6 @@ import { filterResourceScopesForTheThirdPartyApplication, } from './resource.js'; import { getAcceptedUserClaims, getUserClaimsData } from './scope.js'; -import { OIDCExtraParametersKey, InteractionMode } from './type.js'; // Temporarily removed 'EdDSA' since it's not supported by browser yet const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const); @@ -174,8 +178,6 @@ export default function initOidc( }, interactions: { url: (ctx, { params: { client_id: appId }, prompt }) => { - const isDemoApp = appId === demoAppApplicationId; - ctx.cookies.set( logtoCookieKey, JSON.stringify({ @@ -184,20 +186,15 @@ export default function initOidc( { sameSite: 'lax', overwrite: true, httpOnly: false } ); - const appendParameters = (path: string) => { - return isDemoApp ? path + `?no_cache` : path; - }; + const params = trySafe(() => extraParamsObjectGuard.parse(ctx.oidc.params ?? {})) ?? {}; switch (prompt.name) { case 'login': { - const isSignUp = - ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp; - - return appendParameters(isSignUp ? routes.signUp : routes.signIn); + return '/' + buildLoginPromptUrl(params, appId); } case 'consent': { - return routes.consent; + return '/' + experience.routes.consent; } default: { @@ -206,7 +203,7 @@ export default function initOidc( } }, }, - extraParams: [OIDCExtraParametersKey.InteractionMode], + extraParams: Object.values(ExtraParamsKey), extraTokenClaims: async (ctx, token) => { const { isDevFeaturesEnabled, isCloud } = EnvSet.values; diff --git a/packages/core/src/oidc/type.ts b/packages/core/src/oidc/type.ts deleted file mode 100644 index a8db5a1f2..000000000 --- a/packages/core/src/oidc/type.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum OIDCExtraParametersKey { - InteractionMode = 'interaction_mode', -} - -export enum InteractionMode { - signIn = 'signIn', - signUp = 'signUp', -} diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index b45862457..8c71c71dc 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -1,4 +1,11 @@ -import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas'; +import { + ApplicationType, + CustomClientMetadataKey, + FirstScreen, + GrantType, + InteractionMode, + demoAppApplicationId, +} from '@logto/schemas'; import { mockEnvSet } from '#src/test-utils/env-set.js'; @@ -7,6 +14,7 @@ import { buildOidcClientMetadata, getConstantClientMetadata, validateCustomClientMetadata, + buildLoginPromptUrl, } from './utils.js'; describe('getConstantClientMetadata()', () => { @@ -121,3 +129,49 @@ describe('isOriginAllowed', () => { ).toBeTruthy(); }); }); + +describe('buildLoginPromptUrl', () => { + it('should return the correct url for empty parameters', () => { + expect(buildLoginPromptUrl({})).toBe('sign-in'); + expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in'); + expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe('sign-in?no_cache='); + }); + + it('should return the correct url for firstScreen', () => { + expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register })).toBe('register'); + expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register }, 'foo')).toBe('register'); + expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignIn }, demoAppApplicationId)).toBe( + 'sign-in?no_cache=' + ); + // Legacy interactionMode support + expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register'); + }); + + it('should return the correct url for directSignIn', () => { + expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' })).toBe( + 'direct/method/target?fallback=sign-in' + ); + expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, 'foo')).toBe( + 'direct/method/target?fallback=sign-in' + ); + expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, demoAppApplicationId)).toBe( + 'direct/method/target?no_cache=&fallback=sign-in' + ); + expect(buildLoginPromptUrl({ direct_sign_in: 'method' })).toBe( + 'direct/method?fallback=sign-in' + ); + expect(buildLoginPromptUrl({ direct_sign_in: '' })).toBe('sign-in'); + }); + + it('should return the correct url for mixed parameters', () => { + expect( + buildLoginPromptUrl({ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' }) + ).toBe('direct/method/target?fallback=register'); + expect( + buildLoginPromptUrl( + { first_screen: FirstScreen.Register, direct_sign_in: 'method:target' }, + demoAppApplicationId + ) + ).toBe('direct/method/target?no_cache=&fallback=register'); + }); +}); diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 3f630425e..7185acb8a 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -1,5 +1,15 @@ -import type { CustomClientMetadata, OidcClientMetadata } from '@logto/schemas'; -import { ApplicationType, customClientMetadataGuard, GrantType } from '@logto/schemas'; +import path from 'node:path'; + +import type { CustomClientMetadata, ExtraParamsObject, OidcClientMetadata } from '@logto/schemas'; +import { + ApplicationType, + customClientMetadataGuard, + GrantType, + ExtraParamsKey, + demoAppApplicationId, + FirstScreen, + experience, +} from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider'; @@ -69,3 +79,27 @@ export const getUtcStartOfTheDay = (date: Date) => { Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0) ); }; + +export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): string => { + const firstScreenKey = + params[ExtraParamsKey.FirstScreen] ?? + params[ExtraParamsKey.InteractionMode] ?? + FirstScreen.SignIn; + const firstScreen = + firstScreenKey === 'signUp' ? experience.routes.register : experience.routes[firstScreenKey]; + const directSignIn = params[ExtraParamsKey.DirectSignIn]; + const searchParams = new URLSearchParams(); + const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : ''); + + if (appId === demoAppApplicationId) { + searchParams.append('no_cache', ''); + } + + if (directSignIn) { + searchParams.append('fallback', firstScreen); + const [method, target] = directSignIn.split(':'); + return path.join('direct', method ?? '', target ?? '') + getSearchParamString(); + } + + return firstScreen + getSearchParamString(); +}; diff --git a/packages/core/src/routes/consts.ts b/packages/core/src/routes/consts.ts deleted file mode 100644 index 2f07bfa19..000000000 --- a/packages/core/src/routes/consts.ts +++ /dev/null @@ -1,11 +0,0 @@ -const signIn = '/sign-in'; -const signUp = '/register'; -const consent = '/consent'; - -export const routes = Object.freeze({ - signIn, - signUp, - consent, -} as const); - -export const verificationTimeout = 10 * 60 * 1000; // 10 mins. diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 557cea219..e911a5a51 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -1,4 +1,4 @@ -import { adminTenantId } from '@logto/schemas'; +import { adminTenantId, experience } from '@logto/schemas'; import type { MiddlewareType } from 'koa'; import Koa from 'koa'; import compose from 'koa-compose'; @@ -25,7 +25,6 @@ import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; import initOidc from '#src/oidc/init.js'; import { mountCallbackRouter } from '#src/routes/callback.js'; -import { routes } from '#src/routes/consts.js'; import initApis from '#src/routes/init.js'; import initMeApis from '#src/routes-me/init.js'; import BasicSentinel from '#src/sentinel/basic-sentinel.js'; @@ -147,7 +146,7 @@ export default class Tenant implements TenantContext { app.use( compose([ koaSpaSessionGuard(provider, queries), - mount(`${routes.consent}`, koaAutoConsent(provider, queries)), + mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)), koaSpaProxy(mountedApps), ]) ); diff --git a/packages/demo-app/src/App.tsx b/packages/demo-app/src/App.tsx index 1060d4aa2..50c2b8055 100644 --- a/packages/demo-app/src/App.tsx +++ b/packages/demo-app/src/App.tsx @@ -38,7 +38,10 @@ const Main = () => { // If user is not authenticated, redirect to sign-in page if (!isAuthenticated) { - void signIn(window.location.href); + void signIn({ + redirectUri: window.location.origin + window.location.pathname, + extraParams: Object.fromEntries(new URLSearchParams(window.location.search).entries()), + }); } }, [getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]); diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 6fe19045c..db44a29aa 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -1,5 +1,5 @@ import { AppInsightsBoundary } from '@logto/app-insights/react'; -import { MfaFactor } from '@logto/schemas'; +import { MfaFactor, experience } from '@logto/schemas'; import { Route, Routes, BrowserRouter } from 'react-router-dom'; import AppLayout from './Layout/AppLayout'; @@ -8,10 +8,10 @@ import LoadingLayerProvider from './Providers/LoadingLayerProvider'; import PageContextProvider from './Providers/PageContextProvider'; import SettingsProvider from './Providers/SettingsProvider'; import SingleSignOnContextProvider from './Providers/SingleSignOnContextProvider'; -import { singleSignOnPath } from './constants/env'; import Callback from './pages/Callback'; import Consent from './pages/Consent'; import Continue from './pages/Continue'; +import DirectSignIn from './pages/DirectSignIn'; import ErrorPage from './pages/ErrorPage'; import ForgotPassword from './pages/ForgotPassword'; import MfaBinding from './pages/MfaBinding'; @@ -31,7 +31,7 @@ import SingleSignOnConnectors from './pages/SingleSignOnConnectors'; import SingleSignOnEmail from './pages/SingleSignOnEmail'; import SocialLanding from './pages/SocialLanding'; import SocialLinkAccount from './pages/SocialLinkAccount'; -import SocialSignIn from './pages/SocialSignInCallback'; +import SocialSignInWebCallback from './pages/SocialSignInWebCallback'; import Springboard from './pages/Springboard'; import VerificationCode from './pages/VerificationCode'; import { UserMfaFlow } from './types'; @@ -50,23 +50,29 @@ const App = () => { - }> - } - /> + }> } /> + } /> + } + /> + } /> + + }> + } + /> - }> {/* Sign-in */} - + } /> } /> - } /> {/* Register */} - + } /> } /> @@ -106,19 +112,18 @@ const App = () => { } /> } /> - } /> + + {/* Single sign-on */} + }> + } /> + } /> + + + {/* Consent */} + } /> + + } /> - - {/* Single sign-on */} - }> - } /> - } /> - - - {/* Consent */} - } /> - - } /> diff --git a/packages/experience/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx b/packages/experience/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx index 37d875c74..3cb320597 100644 --- a/packages/experience/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx +++ b/packages/experience/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx @@ -12,12 +12,17 @@ type Props = { }; const SettingsProvider = ({ settings = mockSignInExperienceSettings, children }: Props) => { - const { setExperienceSettings } = useContext(PageContext); + const { setExperienceSettings, experienceSettings } = useContext(PageContext); useEffect(() => { setExperienceSettings(settings); }, [setExperienceSettings, settings]); + // Don't render children until the settings are set to avoid false positives + if (!experienceSettings) { + return null; + } + return children; }; diff --git a/packages/experience/src/constants/env.ts b/packages/experience/src/constants/env.ts index e1574f786..85636912e 100644 --- a/packages/experience/src/constants/env.ts +++ b/packages/experience/src/constants/env.ts @@ -4,5 +4,3 @@ export const isDevFeaturesEnabled = process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST); - -export const singleSignOnPath = 'single-sign-on'; diff --git a/packages/experience/src/hooks/use-check-single-sign-on.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts index 23b06e43f..c103d35af 100644 --- a/packages/experience/src/hooks/use-check-single-sign-on.ts +++ b/packages/experience/src/hooks/use-check-single-sign-on.ts @@ -1,11 +1,10 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; +import { experience, type SsoConnectorMetadata } from '@logto/schemas'; import { useCallback, useState, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; -import { singleSignOnPath } from '@/constants/env'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -81,7 +80,7 @@ const useCheckSingleSignOn = () => { return true; } - navigate(`/${singleSignOnPath}/connectors`); + navigate(`/${experience.routes.sso}/connectors`); return true; }, [ diff --git a/packages/experience/src/hooks/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts index 8c2069fd0..3941c3749 100644 --- a/packages/experience/src/hooks/use-single-sign-on-watch.ts +++ b/packages/experience/src/hooks/use-single-sign-on-watch.ts @@ -1,4 +1,4 @@ -import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas'; +import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas'; import { useEffect, useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -6,7 +6,6 @@ import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleS import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; -import { singleSignOnPath } from '@/constants/env'; import useApi from '@/hooks/use-api'; import useSingleSignOn from '@/hooks/use-single-sign-on'; import { validateEmail } from '@/utils/form'; @@ -72,7 +71,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { return; } - navigate(`/${singleSignOnPath}/connectors`); + navigate(`/${experience.routes.sso}/connectors`); }, [navigate, showSingleSignOnForm, singleSignOn, ssoConnectors]); useEffect(() => { diff --git a/packages/experience/src/pages/Callback/index.tsx b/packages/experience/src/pages/Callback/index.tsx index c82f8a901..bc141106f 100644 --- a/packages/experience/src/pages/Callback/index.tsx +++ b/packages/experience/src/pages/Callback/index.tsx @@ -12,7 +12,7 @@ type Parameters = { }; /** - * Callback page for SocialSignIn and SingleSignOn + * Callback landing page for social sign-in and single sign-on. */ const Callback = () => { const { connectorId } = useParams(); diff --git a/packages/experience/src/pages/Callback/use-social-callback-handler.ts b/packages/experience/src/pages/Callback/use-social-callback-handler.ts index 27239ecf8..d4422b636 100644 --- a/packages/experience/src/pages/Callback/use-social-callback-handler.ts +++ b/packages/experience/src/pages/Callback/use-social-callback-handler.ts @@ -33,7 +33,7 @@ const useSocialCallbackHandler = () => { // Web flow navigate( { - pathname: `/sign-in/social/${connectorId}`, + pathname: `/callback/social/${connectorId}`, search, }, { diff --git a/packages/experience/src/pages/DirectSignIn/index.test.tsx b/packages/experience/src/pages/DirectSignIn/index.test.tsx new file mode 100644 index 000000000..c08f3bb40 --- /dev/null +++ b/packages/experience/src/pages/DirectSignIn/index.test.tsx @@ -0,0 +1,81 @@ +import { useParams as useParamsMock } from 'react-router-dom'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { socialConnectors } from '@/__mocks__/logto'; + +import DirectSignIn from '.'; + +jest.mock('@/containers/SocialSignInList/use-social', () => + jest.fn().mockReturnValue({ + socialConnectors, + invokeSocialSignIn: jest.fn(() => { + window.location.assign('/social-redirect-to'); + }), + }) +); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn().mockReturnValue({}), +})); + +const useParams = useParamsMock as jest.Mock; + +const assign = jest.fn(); +const replace = jest.fn(); +const search = jest.fn(); + +// eslint-disable-next-line @silverhand/fp/no-mutating-methods +Object.defineProperty(window, 'location', { + value: { + assign, + replace, + }, + writable: true, +}); + +// eslint-disable-next-line @silverhand/fp/no-mutating-methods +Object.defineProperty(window.location, 'search', { + get: search, +}); + +describe('DirectSignIn', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fallback to the first screen when `directSignIn` is not provided', () => { + renderWithPageContext(); + expect(replace).toBeCalledWith('/sign-in'); + }); + + it('should fallback to the first screen when `directSignIn` is invalid', () => { + useParams.mockReturnValue({ method: 'foo' }); + renderWithPageContext(); + expect(replace).toBeCalledWith('/sign-in'); + }); + + it('should fallback to the first screen provided in the fallback parameter', () => { + useParams.mockReturnValue({ method: 'method', target: 'target' }); + search.mockReturnValue('?fallback=register'); + renderWithPageContext(); + expect(replace).toBeCalledWith('/register'); + }); + + it('should fallback to the first screen when method is valid but target is invalid', () => { + 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', () => { + useParams.mockReturnValue({ method: 'social', target: socialConnectors[0]!.target }); + search.mockReturnValue(`?fallback=sign-in`); + + renderWithPageContext(); + + expect(replace).not.toBeCalled(); + expect(assign).toBeCalledWith('/social-redirect-to'); + }); +}); diff --git a/packages/experience/src/pages/DirectSignIn/index.tsx b/packages/experience/src/pages/DirectSignIn/index.tsx new file mode 100644 index 000000000..43d985a89 --- /dev/null +++ b/packages/experience/src/pages/DirectSignIn/index.tsx @@ -0,0 +1,33 @@ +import { experience } from '@logto/schemas'; +import { useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import LoadingLayer from '@/components/LoadingLayer'; +import useSocial from '@/containers/SocialSignInList/use-social'; + +const DirectSignIn = () => { + const { method, target } = useParams(); + const { socialConnectors, invokeSocialSignIn } = useSocial(); + const fallback = useMemo(() => { + const fallbackKey = new URLSearchParams(window.location.search).get('fallback'); + return ( + Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ?? + experience.routes.signIn + ); + }, []); + + useEffect(() => { + if (method === 'social') { + const social = socialConnectors.find((connector) => connector.target === target); + if (social) { + void invokeSocialSignIn(social); + return; + } + } + + window.location.replace('/' + fallback); + }, [fallback, invokeSocialSignIn, method, socialConnectors, target]); + + return ; +}; +export default DirectSignIn; diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx index 4f48251ef..c79f7bcbb 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx @@ -1,4 +1,4 @@ -import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas'; +import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, act, waitFor } from '@testing-library/react'; @@ -10,7 +10,6 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { registerWithUsernamePassword } from '@/apis/interaction'; import { sendVerificationCodeApi } from '@/apis/utils'; -import { singleSignOnPath } from '@/constants/env'; import { UserFlow } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; @@ -401,7 +400,7 @@ describe('', () => { }); await waitFor(() => { - expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + expect(mockedNavigate).toBeCalledWith(`/${experience.routes.sso}/connectors`); }); }); }); diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx index b6d759254..bfde9a783 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx @@ -1,5 +1,5 @@ import type { SignIn, SsoConnectorMetadata } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, experience } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, act, waitFor } from '@testing-library/react'; @@ -13,7 +13,6 @@ import { mockSsoConnectors, } from '@/__mocks__/logto'; import { sendVerificationCodeApi } from '@/apis/utils'; -import { singleSignOnPath } from '@/constants/env'; import { UserFlow } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; @@ -310,7 +309,7 @@ describe('IdentifierSignInForm', () => { }); await waitFor(() => { - expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + expect(mockedNavigate).toBeCalledWith(`/${experience.routes.sso}/connectors`); }); }); }); diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx index d71cdd85c..98a842071 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx @@ -1,4 +1,4 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, experience } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; @@ -9,7 +9,6 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { signInWithPasswordIdentifier } from '@/apis/interaction'; -import { singleSignOnPath } from '@/constants/env'; import type { SignInExperienceResponse } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; @@ -260,7 +259,7 @@ describe('UsernamePasswordSignInForm', () => { }); await waitFor(() => { - expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + expect(mockedNavigate).toBeCalledWith(`/${experience.routes.sso}/connectors`); }); }); diff --git a/packages/experience/src/pages/SocialSignInCallback/SingleSignOn.tsx b/packages/experience/src/pages/SocialSignInWebCallback/SingleSignOn.tsx similarity index 100% rename from packages/experience/src/pages/SocialSignInCallback/SingleSignOn.tsx rename to packages/experience/src/pages/SocialSignInWebCallback/SingleSignOn.tsx diff --git a/packages/experience/src/pages/SocialSignInCallback/SocialSignIn.tsx b/packages/experience/src/pages/SocialSignInWebCallback/SocialSignIn.tsx similarity index 77% rename from packages/experience/src/pages/SocialSignInCallback/SocialSignIn.tsx rename to packages/experience/src/pages/SocialSignInWebCallback/SocialSignIn.tsx index ec0878111..73c7c2f15 100644 --- a/packages/experience/src/pages/SocialSignInCallback/SocialSignIn.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/SocialSignIn.tsx @@ -1,4 +1,4 @@ -import SignIn from '../SignIn'; +import LoadingLayer from '@/components/LoadingLayer'; import useSocialSignInListener from './use-social-sign-in-listener'; @@ -12,7 +12,7 @@ type Props = { const SocialSignIn = ({ connectorId }: Props) => { useSocialSignInListener(connectorId); - return ; + return ; }; export default SocialSignIn; diff --git a/packages/experience/src/pages/SocialSignInCallback/index.test.tsx b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx similarity index 100% rename from packages/experience/src/pages/SocialSignInCallback/index.test.tsx rename to packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx diff --git a/packages/experience/src/pages/SocialSignInCallback/index.tsx b/packages/experience/src/pages/SocialSignInWebCallback/index.tsx similarity index 65% rename from packages/experience/src/pages/SocialSignInCallback/index.tsx rename to packages/experience/src/pages/SocialSignInWebCallback/index.tsx index 61d9a6ba2..d684d9f1e 100644 --- a/packages/experience/src/pages/SocialSignInCallback/index.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/index.tsx @@ -1,13 +1,13 @@ -import { useParams } from 'react-router-dom'; +import { experience } from '@logto/schemas'; +import { Navigate, useParams } from 'react-router-dom'; import useConnectors from '@/hooks/use-connectors'; -import SignIn from '../SignIn'; - import SingleSignOn from './SingleSignOn'; import SocialSignIn from './SocialSignIn'; -const SocialSignInCallback = () => { +/** The real callback page for social sign-in in web browsers. */ +const SocialSignInWebCallback = () => { const parameters = useParams<{ connectorId: string }>(); const { findConnectorById } = useConnectors(); const result = findConnectorById(parameters.connectorId); @@ -21,7 +21,7 @@ const SocialSignInCallback = () => { } // Connector not found, return sign in page - return ; + return ; }; -export default SocialSignInCallback; +export default SocialSignInWebCallback; diff --git a/packages/experience/src/pages/SocialSignInCallback/use-single-sign-on-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts similarity index 100% rename from packages/experience/src/pages/SocialSignInCallback/use-single-sign-on-listener.ts rename to packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts diff --git a/packages/experience/src/pages/SocialSignInCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts similarity index 100% rename from packages/experience/src/pages/SocialSignInCallback/use-social-sign-in-listener.ts rename to packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 802650759..c5f396a57 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -1,4 +1,4 @@ -import type { LogtoConfig } from '@logto/node'; +import type { LogtoConfig, SignInOptions } from '@logto/node'; import LogtoClient from '@logto/node'; import { demoAppApplicationId } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; @@ -59,8 +59,11 @@ export default class MockClient { return map; } - public async initSession(callbackUri = demoAppRedirectUri) { - await this.logto.signIn(callbackUri); + public async initSession( + redirectUri = demoAppRedirectUri, + options: Omit = {} + ) { + await this.logto.signIn({ redirectUri, ...options }); assert(this.navigateUrl, new Error('Unable to navigate to sign in uri')); assert( @@ -75,7 +78,8 @@ export default class MockClient { // Note: should redirect to sign-in page assert( - response.statusCode === 303 && response.headers.location?.startsWith('/sign-in'), + response.statusCode === 303 && + response.headers.location?.startsWith(options.directSignIn ? '/direct/' : '/sign-in'), new Error('Visit sign in uri failed') ); diff --git a/packages/integration-tests/src/helpers/client.ts b/packages/integration-tests/src/helpers/client.ts index 46594ed2a..652f113f8 100644 --- a/packages/integration-tests/src/helpers/client.ts +++ b/packages/integration-tests/src/helpers/client.ts @@ -1,11 +1,15 @@ -import type { LogtoConfig } from '@logto/node'; +import type { LogtoConfig, SignInOptions } from '@logto/node'; import { assert } from '@silverhand/essentials'; import MockClient from '#src/client/index.js'; -export const initClient = async (config?: Partial, redirectUri?: string) => { +export const initClient = async ( + config?: Partial, + redirectUri?: string, + options: Omit = {} +) => { const client = new MockClient(config); - await client.initSession(redirectUri); + await client.initSession(redirectUri, options); assert(client.interactionCookie, new Error('Session not found')); return client; diff --git a/packages/integration-tests/src/tests/api/interaction/social-interaction/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/social-sign-in/happy-path.test.ts similarity index 90% rename from packages/integration-tests/src/tests/api/interaction/social-interaction/happy-path.test.ts rename to packages/integration-tests/src/tests/api/interaction/social-sign-in/happy-path.test.ts index 961639228..17bbc9525 100644 --- a/packages/integration-tests/src/tests/api/interaction/social-interaction/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/social-sign-in/happy-path.test.ts @@ -30,7 +30,7 @@ const state = 'foo_state'; const redirectUri = 'http://foo.dev/callback'; const code = 'auth_code_foo'; -describe('Social Identifier Interactions', () => { +describe('social sign-in', () => { const connectorIdMap = new Map(); beforeAll(async () => { @@ -47,7 +47,7 @@ describe('Social Identifier Interactions', () => { await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]); }); - describe('register new and sign-in', () => { + describe.only('register and sign-in', () => { const socialUserId = generateUserId(); it('register with social', async () => { @@ -192,6 +192,39 @@ describe('Social Identifier Interactions', () => { await logoutClient(client); await deleteUser(uid); }); + + it('can perform direct sign-in with a new social account', async () => { + const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? ''; + + const client = await initClient(undefined, undefined, { + directSignIn: { method: 'social', target: mockSocialConnectorTarget }, + }); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId }); + + await client.successSend(patchInteractionIdentifiers, { + connectorId, + connectorData: { state, redirectUri, code, userId: socialUserId }, + }); + + await expectRejects(client.submitInteraction(), { + code: 'user.identity_not_exist', + statusCode: 422, + }); + + await client.successSend(putInteractionEvent, { event: InteractionEvent.Register }); + await client.successSend(putInteractionProfile, { connectorId }); + + const { redirectTo } = await client.submitInteraction(); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); + }); }); describe('bind with existing email account', () => { diff --git a/packages/integration-tests/src/tests/api/interaction/social-interaction/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/social-sign-in/sad-path.test.ts similarity index 100% rename from packages/integration-tests/src/tests/api/interaction/social-interaction/sad-path.test.ts rename to packages/integration-tests/src/tests/api/interaction/social-sign-in/sad-path.test.ts 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 new file mode 100644 index 000000000..9c12c1ca1 --- /dev/null +++ b/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts @@ -0,0 +1,68 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { SignInIdentifier } 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 { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; +import ExpectExperience from '#src/ui-helpers/expect-experience.js'; + +/** + * 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', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]); + await setSocialConnector(); + await updateSignInExperience({ + signUp: { identifiers: [], password: true, verify: false }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + socialSignInConnectorTargets: ['mock-social'], + }); + }); + + it('should be landed to the social identity provider directly', async () => { + const experience = new ExpectExperience(await browser.newPage()); + const url = new URL(demoAppUrl); + + url.searchParams.set('direct_sign_in', `social:${mockSocialConnectorTarget}`); + await experience.page.goto(url.href); + await experience.toProcessSocialSignIn({ socialUserId: 'foo', clickButton: false }); + experience.toMatchUrl(demoAppUrl); + await experience.toClick('div[role=button]', /sign out/i); + 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); + + url.searchParams.set('direct_sign_in', 'social:invalid-target'); + await experience.navigateTo(url.href); + experience.toBeAt('sign-in'); + await experience.page.close(); + }); + + it('should fall back to the register page if the direct sign-in target is invalid and `first_screen` is `register`', async () => { + const experience = new ExpectExperience(await browser.newPage()); + const url = new URL(demoAppUrl); + + url.searchParams.set('direct_sign_in', 'social:invalid-target'); + url.searchParams.set('first_screen', 'register'); + await experience.navigateTo(url.href); + experience.toBeAt('register'); + await experience.page.close(); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/password-policy.test.ts b/packages/integration-tests/src/tests/experience/password-policy.test.ts index 338b341c9..8c819844a 100644 --- a/packages/integration-tests/src/tests/experience/password-policy.test.ts +++ b/packages/integration-tests/src/tests/experience/password-policy.test.ts @@ -1,5 +1,7 @@ /* Test the sign-in with different password policies. */ +import crypto from 'node:crypto'; + import { ConnectorType, SignInIdentifier } from '@logto/schemas'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; @@ -7,11 +9,12 @@ import { demoAppUrl } from '#src/constants.js'; import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js'; import { setupUsernameAndEmailExperience } from '#src/ui-helpers/index.js'; -import { waitFor } from '#src/utils.js'; + +const randomString = () => crypto.randomBytes(8).toString('hex'); describe('password policy', () => { - const username = 'test_pass_policy_30'; - const emailName = 'a_good_foo_30'; + const username = 'test_' + randomString(); + const emailName = 'foo_' + randomString(); const email = emailName + '@bar.com'; const invalidPasswords: Array<[string, string | RegExp]> = [ ['123', 'minimum length'], @@ -44,7 +47,7 @@ describe('password policy', () => { await experience.toFillInput('id', username, { submit: true }); // Password tests - experience.toBeAt('register/password'); + await experience.waitForPathname('register/password'); await experience.toFillNewPasswords( ...invalidPasswords, [username + 'A', /product context .* personal information/], @@ -72,9 +75,7 @@ describe('password policy', () => { await experience.toFillInput('id', email, { submit: true }); await experience.toCompleteVerification('register', 'Email'); - // Wait for the password page to load - await waitFor(100); - experience.toBeAt('continue/password'); + await experience.waitForPathname('continue/password'); await experience.toFillNewPasswords( ...invalidPasswords, [emailName, 'personal information'], @@ -101,8 +102,7 @@ describe('password policy', () => { await experience.toCompleteVerification('forgot-password', 'Email'); // Wait for the password page to load - await waitFor(100); - experience.toBeAt('forgot-password/reset'); + await experience.waitForPathname('forgot-password/reset'); await experience.toFillNewPasswords( ...invalidPasswords, [emailName, 'personal information'], @@ -110,7 +110,8 @@ describe('password policy', () => { emailName + 'ABCD135' ); - experience.toBeAt('sign-in'); + await experience.waitForPathname('sign-in'); + await experience.waitForToast(/password changed/i); await experience.toFillInput('identifier', email, { submit: true }); await experience.toFillInput('password', emailName + 'ABCD135', { submit: true }); await experience.verifyThenEnd(); diff --git a/packages/integration-tests/src/ui-helpers/expect-experience.ts b/packages/integration-tests/src/ui-helpers/expect-experience.ts index 9a936807b..f2f637269 100644 --- a/packages/integration-tests/src/ui-helpers/expect-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-experience.ts @@ -90,6 +90,24 @@ export default class ExpectExperience extends ExpectPage { this.#ongoing = { type, initialUrl }; } + async waitForUrl(url: URL, retry = 3) { + // eslint-disable-next-line @silverhand/fp/no-let + let retries = retry; + + do { + if (this.page.url() === url.href) { + return; + } + + // eslint-disable-next-line no-await-in-loop + await this.page.waitForNavigation({ waitUntil: 'networkidle0' }); + } while (retries--); // eslint-disable-line @silverhand/fp/no-mutation + } + + async waitForPathname(pathname: string, retry = 3) { + return this.waitForUrl(this.buildExperienceUrl(pathname), retry); + } + /** * Ensure the experience is ongoing and the page is at the initial URL; then try to click the "sign out" * button (case-insensitive) and close the page. @@ -97,16 +115,11 @@ export default class ExpectExperience extends ExpectPage { * It will clear the ongoing experience if the experience is ended successfully. */ async verifyThenEnd(closePage = true) { - /** - * Wait for the network to be idle since we need to process the sign-in consent - * and handle sign-in success callback, this may take a long time. - */ - await this.page.waitForNetworkIdle(); if (this.#ongoing === undefined) { return this.throwNoOngoingExperienceError(); } - this.toMatchUrl(this.#ongoing.initialUrl); + await this.waitForUrl(this.#ongoing.initialUrl); await this.toClick('div[role=button]', /sign out/i); this.#ongoing = undefined; @@ -227,26 +240,28 @@ export default class ExpectExperience extends ExpectPage { } /** - * Assert the page is at the sign-in page with the mock social sign-in method. - * Click the 'Mock Social' sign in method and visit the mocked 3rd-party social sign-in page and redirect - * back with the given user social data. - * - * @param socialUserData The given user social data. + * Optionally click the "Continue with [social name]" button on the page, then process the social + * sign-in flow with the given user social data. */ async toProcessSocialSignIn({ socialUserId, socialEmail, socialPhone, + clickButton = true, }: { socialUserId: string; socialEmail?: string; socialPhone?: string; + /** Whether to click the "Continue with [social name]" button on the page. */ + clickButton?: boolean; }) { const authPageRequestListener = this.page.waitForRequest((request) => request.url().startsWith(mockSocialAuthPageUrl) ); - await this.toClick('button', 'Continue with Mock Social'); + if (clickButton) { + await this.toClick('button', 'Continue with Mock Social'); + } const result = await authPageRequestListener; diff --git a/packages/schemas/src/consts/experience.ts b/packages/schemas/src/consts/experience.ts new file mode 100644 index 000000000..bc84b6fa1 --- /dev/null +++ b/packages/schemas/src/consts/experience.ts @@ -0,0 +1,10 @@ +const routes = Object.freeze({ + signIn: 'sign-in', + register: 'register', + sso: 'single-sign-on', + consent: 'consent', +}); + +export const experience = Object.freeze({ + routes, +}); diff --git a/packages/schemas/src/consts/index.ts b/packages/schemas/src/consts/index.ts index ac4995afb..e8edd442e 100644 --- a/packages/schemas/src/consts/index.ts +++ b/packages/schemas/src/consts/index.ts @@ -4,3 +4,4 @@ export * from './oidc.js'; export * from './date.js'; export * from './tenant.js'; export * from './subscriptions.js'; +export * from './experience.js'; diff --git a/packages/schemas/src/consts/oidc.ts b/packages/schemas/src/consts/oidc.ts index 741766391..865ed689d 100644 --- a/packages/schemas/src/consts/oidc.ts +++ b/packages/schemas/src/consts/oidc.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { type CustomClientMetadata } from '../foundations/index.js'; import { inSeconds } from './date.js'; @@ -9,3 +11,49 @@ export const customClientMetadataDefault = Object.freeze({ refreshTokenTtlInDays: 14, rotateRefreshToken: true, } as const satisfies Partial); + +export enum ExtraParamsKey { + /** + * @deprecated Use {@link FirstScreen} instead. + * @see {@link InteractionMode} for the available values. + */ + InteractionMode = 'interaction_mode', + /** + * The first screen to show for the user. + * + * @see {@link FirstScreen} for the available values. + */ + FirstScreen = 'first_screen', + /** + * Directly sign in via the specified method. Note that the method must be properly configured + * in Logto. + * + * @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`) + */ + DirectSignIn = 'direct_sign_in', +} + +/** @deprecated Use {@link FirstScreen} instead. */ +export enum InteractionMode { + SignIn = 'signIn', + SignUp = 'signUp', +} + +export enum FirstScreen { + SignIn = 'signIn', + Register = 'register', +} + +export const extraParamsObjectGuard = z + .object({ + [ExtraParamsKey.InteractionMode]: z.nativeEnum(InteractionMode), + [ExtraParamsKey.FirstScreen]: z.nativeEnum(FirstScreen), + [ExtraParamsKey.DirectSignIn]: z.string(), + }) + .partial(); + +export type ExtraParamsObject = z.infer; diff --git a/packages/toolkit/core-kit/package.json b/packages/toolkit/core-kit/package.json index ff7d8b9f3..c99b3e45e 100644 --- a/packages/toolkit/core-kit/package.json +++ b/packages/toolkit/core-kit/package.json @@ -45,6 +45,7 @@ "dependencies": { "@logto/language-kit": "workspace:^1.1.0", "@logto/shared": "workspace:^3.1.0", + "@silverhand/essentials": "^2.9.0", "color": "^4.2.3" }, "optionalDependencies": { @@ -53,7 +54,6 @@ "devDependencies": { "@jest/types": "^29.0.3", "@silverhand/eslint-config": "5.0.0", - "@silverhand/essentials": "^2.9.0", "@silverhand/ts-config": "5.0.0", "@silverhand/ts-config-react": "5.0.0", "@types/color": "^3.0.3", diff --git a/packages/toolkit/core-kit/src/password-policy.ts b/packages/toolkit/core-kit/src/password-policy.ts index 085e0d8d4..e9e460178 100644 --- a/packages/toolkit/core-kit/src/password-policy.ts +++ b/packages/toolkit/core-kit/src/password-policy.ts @@ -3,6 +3,8 @@ import { type webcrypto } from 'node:crypto'; import { type DeepPartial } from '@silverhand/essentials'; import { z } from 'zod'; +import { getPwnPasswordsForTest, isIntegrationTest } from './utils/integration-test.js'; + /** Password policy configuration type. */ export type PasswordPolicy = { /** Policy about password length. */ @@ -300,6 +302,10 @@ export class PasswordPolicyChecker { * @returns Whether the password has been pwned. */ async hasBeenPwned(password: string): Promise { + if (isIntegrationTest()) { + return getPwnPasswordsForTest().includes(password); + } + const hash = await this.subtle.digest('SHA-1', new TextEncoder().encode(password)); const hashHex = Array.from(new Uint8Array(hash)) .map((binary) => binary.toString(16).padStart(2, '0')) diff --git a/packages/toolkit/core-kit/src/utils/integration-test.ts b/packages/toolkit/core-kit/src/utils/integration-test.ts new file mode 100644 index 000000000..1c072b908 --- /dev/null +++ b/packages/toolkit/core-kit/src/utils/integration-test.ts @@ -0,0 +1,10 @@ +import { yes } from '@silverhand/essentials'; + +export const isIntegrationTest = () => yes(process.env.INTEGRATION_TEST); + +export const getPwnPasswordsForTest = () => { + if (!isIntegrationTest()) { + throw new Error('This function should only be called in integration tests'); + } + return Object.freeze(['123456aA', 'test_password']); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ccfa92ef..5b351c95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4165,6 +4165,9 @@ importers: '@logto/shared': specifier: workspace:^3.1.0 version: link:../../shared + '@silverhand/essentials': + specifier: ^2.9.0 + version: 2.9.0 color: specifier: ^4.2.3 version: 4.2.3 @@ -4179,9 +4182,6 @@ importers: '@silverhand/eslint-config': specifier: 5.0.0 version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3) - '@silverhand/essentials': - specifier: ^2.9.0 - version: 2.9.0 '@silverhand/ts-config': specifier: 5.0.0 version: 5.0.0(typescript@5.3.3)