mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(core,ui,test): migrate consent api to interaction (#2873)
This commit is contained in:
parent
14680cc556
commit
60e78656f9
12 changed files with 315 additions and 53 deletions
154
packages/core/src/routes/interaction/consent.test.ts
Normal file
154
packages/core/src/routes/interaction/consent.test.ts
Normal file
|
@ -0,0 +1,154 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { mockUser } from '#src/__mocks__/index.js';
|
||||
import { GrantMock } from '#src/test-utils/oidc-provider.js';
|
||||
import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
import { interactionPrefix } from './const.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const grantSave = jest.fn(async () => 'finalGrantId');
|
||||
const grantAddOIDCScope = jest.fn();
|
||||
const grantAddResourceScope = jest.fn();
|
||||
|
||||
class Grant extends GrantMock {
|
||||
static async find(id: string) {
|
||||
return id === 'exists' ? new Grant() : undefined;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.save = grantSave;
|
||||
this.addOIDCScope = grantAddOIDCScope;
|
||||
this.addResourceScope = grantAddResourceScope;
|
||||
}
|
||||
}
|
||||
|
||||
const { findUserById, updateUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
|
||||
findUserById: jest.fn(async (): Promise<User> => mockUser),
|
||||
updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })),
|
||||
}));
|
||||
|
||||
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
|
||||
assignInteractionResults: jest.fn(),
|
||||
}));
|
||||
|
||||
const { default: interactionRoutes } = await import('./index.js');
|
||||
|
||||
describe('interaction -> consent', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const baseInteractionDetails = {
|
||||
session: { accountId: mockUser.id },
|
||||
params: { client_id: 'clientId' },
|
||||
prompt: { details: {} },
|
||||
};
|
||||
|
||||
it('with empty details and reusing old grant', async () => {
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(
|
||||
jest.fn().mockResolvedValue(baseInteractionDetails),
|
||||
Grant
|
||||
),
|
||||
});
|
||||
|
||||
await sessionRequest.post(`${interactionPrefix}/consent`);
|
||||
expect(grantSave).toHaveBeenCalled();
|
||||
expect(assignInteractionResults).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
consent: { grantId: 'finalGrantId' },
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('with empty details and creating new grant', async () => {
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(
|
||||
jest.fn().mockResolvedValue({
|
||||
...baseInteractionDetails,
|
||||
grantId: 'exists',
|
||||
}),
|
||||
Grant
|
||||
),
|
||||
});
|
||||
|
||||
await sessionRequest.post(`${interactionPrefix}/consent`);
|
||||
|
||||
expect(grantSave).toHaveBeenCalled();
|
||||
expect(assignInteractionResults).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
consent: { grantId: 'finalGrantId' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should save application id when the user first consented', async () => {
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(
|
||||
jest.fn().mockResolvedValue({
|
||||
...baseInteractionDetails,
|
||||
prompt: {
|
||||
name: 'consent',
|
||||
details: {},
|
||||
reasons: ['consent_prompt', 'native_client_prompt'],
|
||||
},
|
||||
}),
|
||||
Grant
|
||||
),
|
||||
});
|
||||
|
||||
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
|
||||
|
||||
await sessionRequest.post(`${interactionPrefix}/consent`);
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
|
||||
});
|
||||
|
||||
it('missingOIDCScope and missingResourceScopes', async () => {
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(
|
||||
jest.fn().mockResolvedValue({
|
||||
...baseInteractionDetails,
|
||||
prompt: {
|
||||
details: {
|
||||
missingOIDCScope: ['scope1', 'scope2'],
|
||||
missingResourceScopes: {
|
||||
resource1: ['scope1', 'scope2'],
|
||||
resource2: ['scope3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
Grant
|
||||
),
|
||||
});
|
||||
|
||||
await sessionRequest.post(`${interactionPrefix}/consent`);
|
||||
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
|
||||
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
|
||||
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
|
||||
expect(assignInteractionResults).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
consent: { grantId: 'finalGrantId' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
73
packages/core/src/routes/interaction/consent.ts
Normal file
73
packages/core/src/routes/interaction/consent.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { adminConsoleApplicationId, UserRole } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { interactionPrefix } from './const.js';
|
||||
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||
|
||||
export default function consentRoutes<T>(
|
||||
router: Router<unknown, WithInteractionDetailsContext<T>>,
|
||||
provider: Provider
|
||||
) {
|
||||
router.post(`${interactionPrefix}/consent`, async (ctx, next) => {
|
||||
const { interactionDetails } = ctx;
|
||||
|
||||
const {
|
||||
session,
|
||||
grantId,
|
||||
params: { client_id },
|
||||
prompt,
|
||||
} = interactionDetails;
|
||||
|
||||
assertThat(session, 'session.not_found');
|
||||
|
||||
const { accountId } = session;
|
||||
|
||||
// Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console
|
||||
if (String(client_id) === adminConsoleApplicationId) {
|
||||
const { roleNames } = await findUserById(accountId);
|
||||
|
||||
assertThat(
|
||||
roleNames.includes(UserRole.Admin),
|
||||
new RequestError({ code: 'auth.forbidden', status: 401 })
|
||||
);
|
||||
}
|
||||
|
||||
const grant =
|
||||
conditional(grantId && (await provider.Grant.find(grantId))) ??
|
||||
new provider.Grant({ accountId, clientId: String(client_id) });
|
||||
|
||||
await saveUserFirstConsentedAppId(accountId, String(client_id));
|
||||
|
||||
// V2: fulfill missing claims / resources
|
||||
const PromptDetailsBody = z.object({
|
||||
missingOIDCScope: z.string().array().optional(),
|
||||
missingResourceScopes: z.object({}).catchall(z.string().array()).optional(),
|
||||
});
|
||||
const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details);
|
||||
|
||||
if (missingOIDCScope) {
|
||||
grant.addOIDCScope(missingOIDCScope.join(' '));
|
||||
}
|
||||
|
||||
if (missingResourceScopes) {
|
||||
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
|
||||
grant.addResourceScope(indicator, scope.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
const finalGrantId = await grant.save();
|
||||
|
||||
// V2: configure consent
|
||||
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
2
packages/core/src/routes/interaction/const.ts
Normal file
2
packages/core/src/routes/interaction/const.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const interactionPrefix = '/interaction';
|
||||
export const verificationPath = 'verification';
|
|
@ -9,6 +9,8 @@ import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
|||
import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
import { verificationPath, interactionPrefix } from './const.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
|
@ -106,18 +108,15 @@ await mockEsmWithActual(
|
|||
})
|
||||
);
|
||||
|
||||
const {
|
||||
default: interactionRoutes,
|
||||
verificationPath,
|
||||
interactionPrefix,
|
||||
} = await import('./index.js');
|
||||
const { default: interactionRoutes } = await import('./index.js');
|
||||
|
||||
describe('session -> interactionRoutes', () => {
|
||||
describe('interaction routes', () => {
|
||||
const baseProviderMock = {
|
||||
params: {},
|
||||
jti: 'jti',
|
||||
client_id: demoAppApplicationId,
|
||||
};
|
||||
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||
|
@ -125,6 +124,10 @@ describe('session -> interactionRoutes', () => {
|
|||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /interaction', () => {
|
||||
|
@ -153,6 +156,36 @@ describe('session -> interactionRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('submit interaction', () => {
|
||||
const path = `${interactionPrefix}/submit`;
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call identifier and profile verification properly', async () => {
|
||||
await sessionRequest.post(path).send();
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyIdentifier).toBeCalled();
|
||||
expect(verifyProfile).toBeCalled();
|
||||
expect(validateMandatoryUserProfile).toBeCalled();
|
||||
expect(submitInteraction).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.ForgotPassword,
|
||||
});
|
||||
|
||||
await sessionRequest.post(path).send();
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyIdentifier).toBeCalled();
|
||||
expect(verifyProfile).toBeCalled();
|
||||
expect(validateMandatoryUserProfile).not.toBeCalled();
|
||||
expect(submitInteraction).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /interaction/event', () => {
|
||||
const path = `${interactionPrefix}/event`;
|
||||
|
||||
|
@ -279,32 +312,6 @@ describe('session -> interactionRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('submit interaction', () => {
|
||||
const path = `${interactionPrefix}/submit`;
|
||||
|
||||
it('should call identifier and profile verification properly', async () => {
|
||||
await sessionRequest.post(path).send();
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyIdentifier).toBeCalled();
|
||||
expect(verifyProfile).toBeCalled();
|
||||
expect(validateMandatoryUserProfile).toBeCalled();
|
||||
expect(submitInteraction).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.ForgotPassword,
|
||||
});
|
||||
|
||||
await sessionRequest.post(path).send();
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyIdentifier).toBeCalled();
|
||||
expect(verifyProfile).toBeCalled();
|
||||
expect(validateMandatoryUserProfile).not.toBeCalled();
|
||||
expect(submitInteraction).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /verification/social/authorization-uri', () => {
|
||||
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
|
||||
import type { AnonymousRouter, RouterInitArgs } from '../types.js';
|
||||
import submitInteraction from './actions/submit-interaction.js';
|
||||
import consentRoutes from './consent.js';
|
||||
import { interactionPrefix, verificationPath } from './const.js';
|
||||
import koaInteractionDetails from './middleware/koa-interaction-details.js';
|
||||
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
|
||||
|
@ -38,10 +40,7 @@ import {
|
|||
validateMandatoryUserProfile,
|
||||
} from './verifications/index.js';
|
||||
|
||||
export const interactionPrefix = '/interaction';
|
||||
export const verificationPath = 'verification';
|
||||
|
||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
||||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
...[anonymousRouter, tenant]: RouterInitArgs<T>
|
||||
|
@ -351,4 +350,6 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
consentRoutes(router, provider);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,26 @@
|
|||
import Provider from 'oidc-provider';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
export const createMockProvider = (interactionDetails?: jest.Mock): Provider => {
|
||||
export abstract class GrantMock {
|
||||
static find: (id: string) => Promise<GrantMock | undefined>;
|
||||
|
||||
save: () => Promise<string>;
|
||||
addOIDCScope: (scope: string) => void;
|
||||
addResourceScope: (resource: string, scope: string) => undefined;
|
||||
|
||||
constructor() {
|
||||
this.save = jest.fn(async () => 'finalGrantId');
|
||||
this.addOIDCScope = jest.fn();
|
||||
this.addResourceScope = jest.fn();
|
||||
}
|
||||
}
|
||||
|
||||
export const createMockProvider = (
|
||||
interactionDetails?: jest.Mock,
|
||||
Grant?: typeof GrantMock
|
||||
): Provider => {
|
||||
const originalWarn = console.warn;
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation((...args) => {
|
||||
// Disable while creating. Too many warnings.
|
||||
|
@ -15,10 +33,15 @@ export const createMockProvider = (interactionDetails?: jest.Mock): Provider =>
|
|||
const provider = new Provider('https://logto.test');
|
||||
|
||||
warn.mockRestore();
|
||||
|
||||
jest.spyOn(provider, 'interactionDetails').mockImplementation(
|
||||
// @ts-expect-error for testing
|
||||
interactionDetails ?? (async () => ({ params: {}, jti: 'jti', client_id: 'mockApplicationId' }))
|
||||
);
|
||||
|
||||
if (Grant) {
|
||||
Sinon.stub(provider, 'Grant').value(Grant);
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import Libraries from '#src/tenants/Libraries.js';
|
|||
import Queries from '#src/tenants/Queries.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
import type { GrantMock } from './oidc-provider.js';
|
||||
import { createMockProvider } from './oidc-provider.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -58,5 +59,7 @@ export class MockTenant implements TenantContext {
|
|||
}
|
||||
}
|
||||
|
||||
export const createMockTenantWithInteraction = (interactionDetails?: jest.Mock) =>
|
||||
new MockTenant(createMockProvider(interactionDetails));
|
||||
export const createMockTenantWithInteraction = (
|
||||
interactionDetails?: jest.Mock,
|
||||
Grant?: typeof GrantMock
|
||||
) => new MockTenant(createMockProvider(interactionDetails, Grant));
|
||||
|
|
|
@ -94,3 +94,13 @@ export const createSocialAuthorizationUri = async (
|
|||
json: payload,
|
||||
followRedirect: false,
|
||||
});
|
||||
|
||||
export const consent = async (cookie: string) =>
|
||||
api
|
||||
.post('interaction/consent', {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
followRedirect: false,
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
|
|
@ -52,16 +52,6 @@ export const signInWithPassword = async ({
|
|||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const consent = async (interactionCookie: string) =>
|
||||
api
|
||||
.post('session/consent', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
followRedirect: false,
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const sendRegisterUserWithEmailPasscode = (email: string, interactionCookie: string) =>
|
||||
api.post('session/passwordless/email/send', {
|
||||
headers: {
|
||||
|
|
|
@ -5,8 +5,7 @@ import type { Nullable, Optional } from '@silverhand/essentials';
|
|||
import { assert } from '@silverhand/essentials';
|
||||
import { got } from 'got';
|
||||
|
||||
import { consent } from '#src/api/index.js';
|
||||
import { submitInteraction } from '#src/api/interaction.js';
|
||||
import { consent, submitInteraction } from '#src/api/index.js';
|
||||
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';
|
||||
|
||||
import { MemoryStorage } from './storage.js';
|
||||
|
|
|
@ -5,5 +5,5 @@ export const consent = async () => {
|
|||
redirectTo: string;
|
||||
};
|
||||
|
||||
return api.post('/api/session/consent').json<Response>();
|
||||
return api.post('/api/interaction/consent').json<Response>();
|
||||
};
|
||||
|
|
|
@ -18,6 +18,6 @@ describe('api', () => {
|
|||
|
||||
it('consent', async () => {
|
||||
await consent();
|
||||
expect(ky.post).toBeCalledWith('/api/session/consent');
|
||||
expect(ky.post).toBeCalledWith('/api/interaction/consent');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue