Skip to content

Commit e213767

Browse files
committed
feat(core): guard one-time token on consent request
1 parent a5c0343 commit e213767

File tree

10 files changed

+205
-50
lines changed

10 files changed

+205
-50
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Provider, errors } from 'oidc-provider';
2+
3+
import { MockTenant } from '#src/test-utils/tenant.js';
4+
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
5+
6+
import koaConsentGuard from './koa-consent-guard.js';
7+
8+
const { jest } = import.meta;
9+
10+
describe('koaConsentGuard middleware', () => {
11+
const provider = new Provider('https://logto.test');
12+
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
13+
14+
const tenant = new MockTenant(undefined, {
15+
users: {
16+
findUserById: jest.fn().mockResolvedValue({ primaryEmail: '[email protected]' }),
17+
},
18+
});
19+
const next = jest.fn();
20+
21+
afterEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
it('should throw an error if session is not found', async () => {
26+
// @ts-expect-error
27+
interactionDetails.mockResolvedValue({
28+
params: { one_time_token: 'token', login_hint: '[email protected]' },
29+
session: undefined,
30+
});
31+
const ctx = createContextWithRouteParameters({
32+
url: `/consent`,
33+
});
34+
const guard = koaConsentGuard(provider, tenant.libraries, tenant.queries);
35+
36+
await expect(guard(ctx, next)).rejects.toThrow(errors.SessionNotFound);
37+
});
38+
39+
it('should not block if token or login_hint are not provided', async () => {
40+
interactionDetails.mockResolvedValue({
41+
params: { one_time_token: '', login_hint: '' },
42+
// @ts-expect-error
43+
session: { accountId: 'foo' },
44+
});
45+
const ctx = createContextWithRouteParameters({
46+
url: `/consent`,
47+
});
48+
const guard = koaConsentGuard(provider, tenant.libraries, tenant.queries);
49+
50+
await guard(ctx, next);
51+
expect(tenant.queries.users.findUserById).not.toHaveBeenCalled();
52+
expect(tenant.libraries.oneTimeTokens.verifyOneTimeToken).not.toHaveBeenCalled();
53+
expect(next).toHaveBeenCalled();
54+
});
55+
56+
it('should redirect to switch account page if email does not match', async () => {
57+
interactionDetails.mockResolvedValue({
58+
params: { one_time_token: 'abcdefg', login_hint: '[email protected]' },
59+
// @ts-expect-error
60+
session: { accountId: 'bar' },
61+
});
62+
const ctx = createContextWithRouteParameters({
63+
url: `/consent`,
64+
});
65+
const guard = koaConsentGuard(provider, tenant.libraries, tenant.queries);
66+
67+
await guard(ctx, jest.fn());
68+
expect(ctx.redirect).toHaveBeenCalledWith(expect.stringContaining('switch-account'));
69+
});
70+
71+
it('should call next middleware if validations pass', async () => {
72+
const ctx = createContextWithRouteParameters({
73+
url: `/consent`,
74+
});
75+
interactionDetails.mockResolvedValue({
76+
params: { one_time_token: 'token_value', login_hint: '[email protected]' },
77+
// @ts-expect-error
78+
session: { accountId: 'foo' },
79+
});
80+
const guard = koaConsentGuard(provider, tenant.libraries, tenant.queries);
81+
82+
await guard(ctx, next);
83+
expect(next).toHaveBeenCalled();
84+
});
85+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { experience } from '@logto/schemas';
2+
import { type MiddlewareType } from 'koa';
3+
import { type IRouterParamContext } from 'koa-router';
4+
import { errors, type Provider } from 'oidc-provider';
5+
6+
import type Libraries from '../tenants/Libraries.js';
7+
import type Queries from '../tenants/Queries.js';
8+
import assertThat from '../utils/assert-that.js';
9+
10+
/**
11+
* Guard before allowing auto-consent
12+
*/
13+
export default function koaConsentGuard<
14+
StateT,
15+
ContextT extends IRouterParamContext,
16+
ResponseBodyT,
17+
>(
18+
provider: Provider,
19+
libraries: Libraries,
20+
query: Queries
21+
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
22+
return async (ctx, next) => {
23+
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
24+
const {
25+
params: { one_time_token: token, login_hint: loginHint },
26+
session,
27+
} = interactionDetails;
28+
29+
assertThat(session, new errors.SessionNotFound('session not found'));
30+
31+
if (token && loginHint && typeof token === 'string' && typeof loginHint === 'string') {
32+
const { primaryEmail } = await query.users.findUserById(session.accountId);
33+
34+
assertThat(primaryEmail, 'user.email_not_exist');
35+
36+
if (primaryEmail !== loginHint) {
37+
const searchParams = new URLSearchParams({ login_hint: loginHint, one_time_token: token });
38+
ctx.redirect(`${experience.routes.switchAccount}?${searchParams.toString()}`);
39+
return;
40+
}
41+
}
42+
43+
return next();
44+
};
45+
}

packages/core/src/tenants/Tenant.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import BasicSentinel from '#src/sentinel/basic-sentinel.js';
3232

3333
import { redisCache } from '../caches/index.js';
3434
import { SubscriptionLibrary } from '../libraries/subscription.js';
35+
import koaConsentGuard from '../middleware/koa-consent-guard.js';
3536

3637
import Libraries from './Libraries.js';
3738
import Queries from './Queries.js';
@@ -201,7 +202,13 @@ export default class Tenant implements TenantContext {
201202
compose([
202203
koaExperienceSsr(libraries, queries),
203204
koaSpaSessionGuard(provider, queries),
204-
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
205+
mount(
206+
`/${experience.routes.consent}`,
207+
compose([
208+
koaConsentGuard(provider, libraries, queries),
209+
koaAutoConsent(provider, queries),
210+
])
211+
),
205212
koaSpaProxy({ mountedApps, queries }),
206213
])
207214
);

packages/experience/src/App.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import SocialLanding from './pages/SocialLanding';
3939
import SocialLinkAccount from './pages/SocialLinkAccount';
4040
import SocialSignInWebCallback from './pages/SocialSignInWebCallback';
4141
import Springboard from './pages/Springboard';
42+
import SwitchAccount from './pages/SwitchAccount';
4243
import VerificationCode from './pages/VerificationCode';
4344
import { UserMfaFlow } from './types';
4445
import { handleSearchParametersData } from './utils/search-parameters';
@@ -65,7 +66,10 @@ const App = () => {
6566
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
6667
<Route element={<AppLayout />}>
6768
{isDevFeaturesEnabled && (
68-
<Route path="one-time-token/:token" element={<OneTimeToken />} />
69+
<>
70+
<Route path="one-time-token" element={<OneTimeToken />} />
71+
<Route path={experience.routes.switchAccount} element={<SwitchAccount />} />
72+
</>
6973
)}
7074
<Route
7175
path="unknown-session"

packages/experience/src/pages/OneTimeToken/index.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
22
AgreeToTermsPolicy,
3+
ExtraParamsKey,
34
InteractionEvent,
45
SignInIdentifier,
56
type RequestErrorBody,
67
} from '@logto/schemas';
78
import { condString } from '@silverhand/essentials';
89
import { useCallback, useEffect, useState } from 'react';
9-
import { useParams } from 'react-router-dom';
10+
import { useSearchParams } from 'react-router-dom';
1011

1112
import {
1213
identifyAndSubmitInteraction,
@@ -17,15 +18,13 @@ import LoadingLayer from '@/components/LoadingLayer';
1718
import useApi from '@/hooks/use-api';
1819
import useErrorHandler from '@/hooks/use-error-handler';
1920
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
20-
import useLoginHint from '@/hooks/use-login-hint';
2121
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
2222
import useTerms from '@/hooks/use-terms';
2323

2424
import ErrorPage from '../ErrorPage';
2525

2626
const OneTimeToken = () => {
27-
const { token } = useParams();
28-
const email = useLoginHint();
27+
const [params] = useSearchParams();
2928
const [oneTimeTokenError, setOneTimeTokenError] = useState<RequestErrorBody | boolean>();
3029

3130
const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
@@ -90,6 +89,8 @@ const OneTimeToken = () => {
9089

9190
useEffect(() => {
9291
(async () => {
92+
const token = params.get(ExtraParamsKey.OneTimeToken);
93+
const email = params.get(ExtraParamsKey.LoginHint);
9394
if (!token || !email) {
9495
setOneTimeTokenError(true);
9596
return;
@@ -125,8 +126,7 @@ const OneTimeToken = () => {
125126
})();
126127
}, [
127128
agreeToTermsPolicy,
128-
email,
129-
token,
129+
params,
130130
asyncRegisterWithOneTimeToken,
131131
handleError,
132132
termsValidation,

packages/experience/src/pages/Register/index.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
1+
import { AgreeToTermsPolicy, ExtraParamsKey, SignInMode } from '@logto/schemas';
22
import { useCallback, useContext } from 'react';
33
import { useTranslation } from 'react-i18next';
4-
import { Navigate, useNavigate } from 'react-router-dom';
4+
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
55

66
import LandingPageLayout from '@/Layout/LandingPageLayout';
77
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@@ -26,6 +26,7 @@ const RegisterFooter = () => {
2626
useSieMethods();
2727
const { termsValidation, agreeToTermsPolicy } = useTerms();
2828
const navigate = useNavigate();
29+
const [params] = useSearchParams();
2930

3031
const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
3132

@@ -41,6 +42,12 @@ const RegisterFooter = () => {
4142
navigate('/single-sign-on/email');
4243
}, [agreeToTermsPolicy, navigate, termsValidation]);
4344

45+
if (params.get(ExtraParamsKey.OneTimeToken)) {
46+
return (
47+
<Navigate replace to={{ pathname: '/one-time-token', search: `?${params.toString()}` }} />
48+
);
49+
}
50+
4451
/* Hide footers when showing Single Sign On form */
4552
if (showSingleSignOnForm) {
4653
return null;

packages/experience/src/pages/SignIn/index.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
1+
import { AgreeToTermsPolicy, ExtraParamsKey, SignInMode } from '@logto/schemas';
22
import { useCallback, useContext } from 'react';
33
import { useTranslation } from 'react-i18next';
4-
import { Navigate, useNavigate } from 'react-router-dom';
4+
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
55

66
import LandingPageLayout from '@/Layout/LandingPageLayout';
77
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@@ -24,6 +24,7 @@ const SignInFooters = () => {
2424
const { t } = useTranslation();
2525
const { termsValidation, agreeToTermsPolicy } = useTerms();
2626
const navigate = useNavigate();
27+
const [params] = useSearchParams();
2728

2829
const { signInMethods, signUpMethods, socialConnectors, signInMode, singleSignOnEnabled } =
2930
useSieMethods();
@@ -94,6 +95,7 @@ const SignInFooters = () => {
9495
const SignIn = () => {
9596
const { signInMethods, socialConnectors, signInMode } = useSieMethods();
9697
const { agreeToTermsPolicy } = useTerms();
98+
const [params] = useSearchParams();
9799

98100
if (!signInMode) {
99101
return <ErrorPage />;
@@ -103,6 +105,12 @@ const SignIn = () => {
103105
return <Navigate to="/register" />;
104106
}
105107

108+
if (params.get(ExtraParamsKey.OneTimeToken)) {
109+
return (
110+
<Navigate replace to={{ pathname: '/one-time-token', search: `?${params.toString()}` }} />
111+
);
112+
}
113+
106114
return (
107115
<LandingPageLayout title="description.sign_in_to_your_account">
108116
<GoogleOneTap context="signin" />

packages/experience/src/pages/SwitchAccount/index.module.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
.title {
1010
margin-top: _.unit(8);
1111
font: var(--font-label-2);
12+
align-self: flex-start;
1213
}
1314

1415
.message {
1516
margin-top: _.unit(6);
1617
font: var(--font-body-2);
18+
align-self: flex-start;
1719
}
1820

1921
.logo {
@@ -36,14 +38,12 @@
3638

3739
:global(body.mobile) {
3840
.title {
39-
@include _.title;
4041
margin-bottom: _.unit(4);
4142
}
4243
}
4344

4445
:global(body.desktop) {
4546
.title {
46-
@include _.title-desktop;
4747
margin-bottom: _.unit(2);
4848
}
4949
}

0 commit comments

Comments
 (0)