mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat: support direct sign-in (#5536)
* feat: support direct sign-in * chore: add changesets * refactor: add test cases * chore(deps): upgrade logto sdks
This commit is contained in:
parent
80487fae78
commit
2cbc591ff6
45 changed files with 573 additions and 131 deletions
27
.changeset/hip-ladybugs-fry.md
Normal file
27
.changeset/hip-ladybugs-fry.md
Normal file
|
@ -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.
|
9
.changeset/nasty-beds-flash.md
Normal file
9
.changeset/nasty-beds-flash.md
Normal file
|
@ -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.
|
15
.changeset/rude-radios-clean.md
Normal file
15
.changeset/rude-radios-clean.md
Normal file
|
@ -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=<method>:<target>`, where:
|
||||
|
||||
- `<method>` is the sign-in method to trigger. Currently the only supported value is `social`.
|
||||
- `<target>` 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.
|
7
.changeset/smart-walls-occur.md
Normal file
7
.changeset/smart-walls-occur.md
Normal file
|
@ -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.
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
export enum OIDCExtraParametersKey {
|
||||
InteractionMode = 'interaction_mode',
|
||||
}
|
||||
|
||||
export enum InteractionMode {
|
||||
signIn = 'signIn',
|
||||
signUp = 'signUp',
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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.
|
|
@ -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),
|
||||
])
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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 = () => {
|
|||
<AppBoundary>
|
||||
<AppInsightsBoundary cloudRole="ui">
|
||||
<Routes>
|
||||
<Route element={<LoadingLayerProvider />}>
|
||||
<Route path="springboard" element={<Springboard />} />
|
||||
<Route path="callback/:connectorId" element={<Callback />} />
|
||||
<Route
|
||||
path="callback/social/:connectorId"
|
||||
element={<SocialSignInWebCallback />}
|
||||
/>
|
||||
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
|
||||
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
path="unknown-session"
|
||||
element={<ErrorPage message="error.invalid_session" />}
|
||||
/>
|
||||
<Route path="springboard" element={<Springboard />} />
|
||||
|
||||
<Route element={<LoadingLayerProvider />}>
|
||||
{/* Sign-in */}
|
||||
<Route path="sign-in">
|
||||
<Route path={experience.routes.signIn}>
|
||||
<Route index element={<SignIn />} />
|
||||
<Route path="password" element={<SignInPassword />} />
|
||||
<Route path="social/:connectorId" element={<SocialSignIn />} />
|
||||
</Route>
|
||||
|
||||
{/* Register */}
|
||||
<Route path="register">
|
||||
<Route path={experience.routes.register}>
|
||||
<Route index element={<Register />} />
|
||||
<Route path="password" element={<RegisterPassword />} />
|
||||
</Route>
|
||||
|
@ -106,11 +112,9 @@ const App = () => {
|
|||
<Route path="link/:connectorId" element={<SocialLinkAccount />} />
|
||||
<Route path="landing/:connectorId" element={<SocialLanding />} />
|
||||
</Route>
|
||||
<Route path="callback/:connectorId" element={<Callback />} />
|
||||
</Route>
|
||||
|
||||
{/* Single sign-on */}
|
||||
<Route path={singleSignOnPath} element={<LoadingLayerProvider />}>
|
||||
<Route path={experience.routes.sso} element={<LoadingLayerProvider />}>
|
||||
<Route path="email" element={<SingleSignOnEmail />} />
|
||||
<Route path="connectors" element={<SingleSignOnConnectors />} />
|
||||
</Route>
|
||||
|
@ -120,6 +124,7 @@ const App = () => {
|
|||
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppInsightsBoundary>
|
||||
</AppBoundary>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
[
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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<Parameters>();
|
||||
|
|
|
@ -33,7 +33,7 @@ const useSocialCallbackHandler = () => {
|
|||
// Web flow
|
||||
navigate(
|
||||
{
|
||||
pathname: `/sign-in/social/${connectorId}`,
|
||||
pathname: `/callback/social/${connectorId}`,
|
||||
search,
|
||||
},
|
||||
{
|
||||
|
|
81
packages/experience/src/pages/DirectSignIn/index.test.tsx
Normal file
81
packages/experience/src/pages/DirectSignIn/index.test.tsx
Normal file
|
@ -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(<DirectSignIn />);
|
||||
expect(replace).toBeCalledWith('/sign-in');
|
||||
});
|
||||
|
||||
it('should fallback to the first screen when `directSignIn` is invalid', () => {
|
||||
useParams.mockReturnValue({ method: 'foo' });
|
||||
renderWithPageContext(<DirectSignIn />);
|
||||
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(<DirectSignIn />);
|
||||
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(<DirectSignIn />);
|
||||
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(<DirectSignIn />);
|
||||
|
||||
expect(replace).not.toBeCalled();
|
||||
expect(assign).toBeCalledWith('/social-redirect-to');
|
||||
});
|
||||
});
|
33
packages/experience/src/pages/DirectSignIn/index.tsx
Normal file
33
packages/experience/src/pages/DirectSignIn/index.tsx
Normal file
|
@ -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 <LoadingLayer />;
|
||||
};
|
||||
export default DirectSignIn;
|
|
@ -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('<IdentifierRegisterForm />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
|
||||
expect(mockedNavigate).toBeCalledWith(`/${experience.routes.sso}/connectors`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 <SignIn />;
|
||||
return <LoadingLayer />;
|
||||
};
|
||||
|
||||
export default SocialSignIn;
|
|
@ -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 <SignIn />;
|
||||
return <Navigate to={experience.routes.signIn} />;
|
||||
};
|
||||
|
||||
export default SocialSignInCallback;
|
||||
export default SocialSignInWebCallback;
|
|
@ -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<SignInOptions, 'redirectUri'> = {}
|
||||
) {
|
||||
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')
|
||||
);
|
||||
|
||||
|
|
|
@ -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<LogtoConfig>, redirectUri?: string) => {
|
||||
export const initClient = async (
|
||||
config?: Partial<LogtoConfig>,
|
||||
redirectUri?: string,
|
||||
options: Omit<SignInOptions, 'redirectUri'> = {}
|
||||
) => {
|
||||
const client = new MockClient(config);
|
||||
await client.initSession(redirectUri);
|
||||
await client.initSession(redirectUri, options);
|
||||
assert(client.interactionCookie, new Error('Session not found'));
|
||||
|
||||
return client;
|
||||
|
|
|
@ -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<string, string>();
|
||||
|
||||
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', () => {
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
||||
if (clickButton) {
|
||||
await this.toClick('button', 'Continue with Mock Social');
|
||||
}
|
||||
|
||||
const result = await authPageRequestListener;
|
||||
|
||||
|
|
10
packages/schemas/src/consts/experience.ts
Normal file
10
packages/schemas/src/consts/experience.ts
Normal file
|
@ -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,
|
||||
});
|
|
@ -4,3 +4,4 @@ export * from './oidc.js';
|
|||
export * from './date.js';
|
||||
export * from './tenant.js';
|
||||
export * from './subscriptions.js';
|
||||
export * from './experience.js';
|
||||
|
|
|
@ -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<CustomClientMetadata>);
|
||||
|
||||
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:
|
||||
*
|
||||
* - `<method>` (e.g. `email`, `sms`)
|
||||
* - `social:<target>` (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<typeof extraParamsObjectGuard>;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<boolean> {
|
||||
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'))
|
||||
|
|
10
packages/toolkit/core-kit/src/utils/integration-test.ts
Normal file
10
packages/toolkit/core-kit/src/utils/integration-test.ts
Normal file
|
@ -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']);
|
||||
};
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue