Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): guard one-time token on consent request #7160

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/core/src/middleware/koa-consent-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Provider } from 'oidc-provider';

import RequestError from '#src/errors/RequestError/index.js';
import { MockQueries } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import koaConsentGuard from './koa-consent-guard.js';

const { jest } = import.meta;

describe('koaConsentGuard middleware', () => {
const provider = new Provider('https://logto.test');
const interactionDetails = jest.spyOn(provider, 'interactionDetails');

const mockQueries = new MockQueries({
users: {
findUserById: jest.fn().mockResolvedValue({ primaryEmail: '[email protected]' }),
},
});

const next = jest.fn();

afterEach(() => {
jest.clearAllMocks();
});

it('should throw an error if session is not found', async () => {
// @ts-expect-error
interactionDetails.mockResolvedValue({
params: { one_time_token: 'token', login_hint: '[email protected]' },
session: undefined,
});
const ctx = createContextWithRouteParameters({
url: `/consent`,
});
const guard = koaConsentGuard(provider, mockQueries);

await expect(guard(ctx, next)).rejects.toThrow(new RequestError({ code: 'session.not_found' }));
});

it('should not block if token or login_hint are not provided', async () => {
interactionDetails.mockResolvedValue({
params: { one_time_token: '', login_hint: '' },
// @ts-expect-error
session: { accountId: 'foo' },
});
const ctx = createContextWithRouteParameters({
url: `/consent`,
});
const guard = koaConsentGuard(provider, mockQueries);

await guard(ctx, next);
expect(mockQueries.users.findUserById).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

it('should redirect to switch account page if email does not match', async () => {
interactionDetails.mockResolvedValue({
params: { one_time_token: 'abcdefg', login_hint: '[email protected]' },
// @ts-expect-error
session: { accountId: 'bar' },
});
const ctx = createContextWithRouteParameters({
url: `/consent`,
});
const guard = koaConsentGuard(provider, mockQueries);

await guard(ctx, jest.fn());
expect(ctx.redirect).toHaveBeenCalledWith(
expect.stringContaining('switch-account?login_hint=bar%40example.com&one_time_token=abcdefg')
);
});

it('should call next middleware if validations pass', async () => {
const ctx = createContextWithRouteParameters({
url: `/consent`,
});
interactionDetails.mockResolvedValue({
params: { one_time_token: 'token_value', login_hint: '[email protected]' },
// @ts-expect-error
session: { accountId: 'foo' },
});
const guard = koaConsentGuard(provider, mockQueries);

await guard(ctx, next);
expect(next).toHaveBeenCalled();
});
});
42 changes: 42 additions & 0 deletions packages/core/src/middleware/koa-consent-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { experience } from '@logto/schemas';
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import { type Provider } from 'oidc-provider';

import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

/**
* Guard before allowing auto-consent.
* E.g. Check if the active session matches the upcoming one-time token auth request.
*/
export default function koaConsentGuard<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT,
>(provider: Provider, query: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const {
params: { one_time_token: token, login_hint: loginHint },
session,
} = interactionDetails;

assertThat(session, new RequestError({ code: 'session.not_found' }));

if (token && loginHint && typeof token === 'string' && typeof loginHint === 'string') {
const { primaryEmail } = await query.users.findUserById(session.accountId);

assertThat(primaryEmail, 'user.email_not_exist');

if (primaryEmail !== loginHint) {
const searchParams = new URLSearchParams({ login_hint: loginHint, one_time_token: token });
ctx.redirect(`${experience.routes.switchAccount}?${searchParams.toString()}`);
return;
}
}

return next();
};
}
6 changes: 5 additions & 1 deletion packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import BasicSentinel from '#src/sentinel/basic-sentinel.js';

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

import Libraries from './Libraries.js';
import Queries from './Queries.js';
Expand Down Expand Up @@ -201,7 +202,10 @@ export default class Tenant implements TenantContext {
compose([
koaExperienceSsr(libraries, queries),
koaSpaSessionGuard(provider, queries),
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
mount(
`/${experience.routes.consent}`,
compose([koaConsentGuard(provider, queries), koaAutoConsent(provider, queries)])
),
koaSpaProxy({ mountedApps, queries }),
])
);
Expand Down
6 changes: 5 additions & 1 deletion packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import SocialLanding from './pages/SocialLanding';
import SocialLinkAccount from './pages/SocialLinkAccount';
import SocialSignInWebCallback from './pages/SocialSignInWebCallback';
import Springboard from './pages/Springboard';
import SwitchAccount from './pages/SwitchAccount';
import VerificationCode from './pages/VerificationCode';
import { UserMfaFlow } from './types';
import { handleSearchParametersData } from './utils/search-parameters';
Expand All @@ -65,7 +66,10 @@ const App = () => {
<Route path="direct/:method/:target?" element={<DirectSignIn />} />
<Route element={<AppLayout />}>
{isDevFeaturesEnabled && (
<Route path="one-time-token/:token" element={<OneTimeToken />} />
<>
<Route path="one-time-token" element={<OneTimeToken />} />
<Route path={experience.routes.switchAccount} element={<SwitchAccount />} />
</>
)}
<Route
path="unknown-session"
Expand Down
12 changes: 6 additions & 6 deletions packages/experience/src/pages/OneTimeToken/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
AgreeToTermsPolicy,
ExtraParamsKey,
InteractionEvent,
SignInIdentifier,
type RequestErrorBody,
} from '@logto/schemas';
import { condString } from '@silverhand/essentials';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';

import {
identifyAndSubmitInteraction,
Expand All @@ -17,15 +18,13 @@ import LoadingLayer from '@/components/LoadingLayer';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useLoginHint from '@/hooks/use-login-hint';
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
import useTerms from '@/hooks/use-terms';

import ErrorPage from '../ErrorPage';

const OneTimeToken = () => {
const { token } = useParams();
const email = useLoginHint();
const [params] = useSearchParams();
const [oneTimeTokenError, setOneTimeTokenError] = useState<RequestErrorBody | boolean>();

const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
Expand Down Expand Up @@ -90,6 +89,8 @@ const OneTimeToken = () => {

useEffect(() => {
(async () => {
const token = params.get(ExtraParamsKey.OneTimeToken);
const email = params.get(ExtraParamsKey.LoginHint);
if (!token || !email) {
setOneTimeTokenError(true);
return;
Expand Down Expand Up @@ -125,8 +126,7 @@ const OneTimeToken = () => {
})();
}, [
agreeToTermsPolicy,
email,
token,
params,
asyncRegisterWithOneTimeToken,
handleError,
termsValidation,
Expand Down
11 changes: 9 additions & 2 deletions packages/experience/src/pages/Register/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
import { AgreeToTermsPolicy, ExtraParamsKey, SignInMode } from '@logto/schemas';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate, useNavigate } from 'react-router-dom';
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';

import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
Expand All @@ -26,6 +26,7 @@ const RegisterFooter = () => {
useSieMethods();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate();
const [params] = useSearchParams();

const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);

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

if (params.get(ExtraParamsKey.OneTimeToken)) {
return (
<Navigate replace to={{ pathname: '/one-time-token', search: `?${params.toString()}` }} />
);
}

/* Hide footers when showing Single Sign On form */
if (showSingleSignOnForm) {
return null;
Expand Down
12 changes: 10 additions & 2 deletions packages/experience/src/pages/SignIn/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
import { AgreeToTermsPolicy, ExtraParamsKey, SignInMode } from '@logto/schemas';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate, useNavigate } from 'react-router-dom';
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';

import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
Expand All @@ -24,6 +24,7 @@ const SignInFooters = () => {
const { t } = useTranslation();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate();
const [params] = useSearchParams();

const { signInMethods, signUpMethods, socialConnectors, signInMode, singleSignOnEnabled } =
useSieMethods();
Expand Down Expand Up @@ -94,6 +95,7 @@ const SignInFooters = () => {
const SignIn = () => {
const { signInMethods, socialConnectors, signInMode } = useSieMethods();
const { agreeToTermsPolicy } = useTerms();
const [params] = useSearchParams();

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

if (params.get(ExtraParamsKey.OneTimeToken)) {
return (
<Navigate replace to={{ pathname: '/one-time-token', search: `?${params.toString()}` }} />
);
}

return (
<LandingPageLayout title="description.sign_in_to_your_account">
<GoogleOneTap context="signin" />
Expand Down
4 changes: 2 additions & 2 deletions packages/experience/src/pages/SwitchAccount/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
.title {
margin-top: _.unit(8);
font: var(--font-label-2);
align-self: flex-start;
}

.message {
margin-top: _.unit(6);
font: var(--font-body-2);
align-self: flex-start;
}

.logo {
Expand All @@ -36,14 +38,12 @@

:global(body.mobile) {
.title {
@include _.title;
margin-bottom: _.unit(4);
}
}

:global(body.desktop) {
.title {
@include _.title-desktop;
margin-bottom: _.unit(2);
}
}
Loading
Loading