Skip to content

Commit 4ae5524

Browse files
committed
feat(core): guard one-time token on consent request
1 parent 08b7b01 commit 4ae5524

File tree

3 files changed

+192
-1
lines changed

3 files changed

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

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
);

0 commit comments

Comments
 (0)