0
Fork 0
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:
simeng-li 2023-01-10 11:59:24 +08:00 committed by GitHub
parent 14680cc556
commit 60e78656f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 315 additions and 53 deletions

View 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()
);
});
});

View 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();
});
}

View file

@ -0,0 +1,2 @@
export const interactionPrefix = '/interaction';
export const verificationPath = 'verification';

View file

@ -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`;

View file

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

View file

@ -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;
};

View file

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

View file

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

View file

@ -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: {

View file

@ -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';

View file

@ -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>();
};

View file

@ -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');
});
});