0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(core): add koaAutoConsent middleware (#5078)

* refactor(core): add koaAutoConsent middleware

Add koaAutoConsent middleware to allow auto consent and redirect direactly from the server side.

* refactor(core): use koaMount to apply the consent middleware

use koaMount to apply the consent middleware

* feat(test): update the integration tests

update the integration tests

* fix(test): update the unit test for consent

update the unit test for consent
This commit is contained in:
simeng-li 2024-01-05 10:00:31 +08:00 committed by GitHub
parent 7a68967267
commit 9669fc92fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 263 additions and 221 deletions

View file

@ -14,13 +14,13 @@ import { type ConnectorLibrary } from './connector.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
const notNumber = (): never => {
throw new Error('Only support usage query for numberic quota');
throw new Error('Only support usage query for numeric quota');
};
export const createQuotaLibrary = (
queries: Queries,
cloudConnection: CloudConnectionLibrary,
connectorLibraray: ConnectorLibrary
connectorLibrary: ConnectorLibrary
) => {
const {
applications: { countNonM2mApplications, countM2mApplications },
@ -31,7 +31,7 @@ export const createQuotaLibrary = (
rolesScopes: { countRolesScopesByRoleId },
} = queries;
const { getLogtoConnectors } = connectorLibraray;
const { getLogtoConnectors } = connectorLibrary;
const tenantUsageQueries: Record<
keyof FeatureQuota,

View file

@ -0,0 +1,143 @@
import { type User } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import type Provider from 'oidc-provider';
import { mockUser } from '#src/__mocks__/user.js';
import type Queries from '#src/tenants/Queries.js';
import { GrantMock, createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { consent } from './session.js';
const { jest } = import.meta;
const grantSave = jest.fn(async (id: string) => id);
const grantAddOIDCScope = jest.fn();
const grantAddResourceScope = jest.fn();
class Grant extends GrantMock {
static async find(id: string) {
return id === 'exists' ? existGrant : undefined;
}
id: string;
accountId?: string;
constructor() {
super();
this.id = generateStandardId();
this.save = async () => grantSave(this.id);
this.addOIDCScope = grantAddOIDCScope;
this.addResourceScope = grantAddResourceScope;
}
}
const existGrant = new Grant();
const userQueries = {
findUserById: jest.fn(async (): Promise<User> => mockUser),
updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })),
};
// @ts-expect-error
const queries: Queries = { users: userQueries };
const context = createContextWithRouteParameters();
type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
describe('consent', () => {
afterEach(() => {
jest.clearAllMocks();
});
const baseInteractionDetails = {
session: { accountId: mockUser.id },
params: { client_id: 'clientId' },
prompt: { details: {} },
} as unknown as Interaction;
it('should update with new grantId if not exist', async () => {
const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant);
await consent(context, provider, queries, baseInteractionDetails);
expect(grantSave).toHaveBeenCalled();
expect(provider.interactionResult).toHaveBeenCalledWith(
context.req,
context.res,
{
...baseInteractionDetails.result,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
consent: { grantId: expect.any(String) },
},
{
mergeWithLastSubmission: true,
}
);
});
it('should update with existing grantId if exist', async () => {
const interactionDetails = {
...baseInteractionDetails,
grantId: 'exists',
} as unknown as Interaction;
const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant);
await consent(context, provider, queries, interactionDetails);
expect(grantSave).toHaveBeenCalled();
expect(provider.interactionResult).toHaveBeenCalledWith(
context.req,
context.res,
{
...baseInteractionDetails.result,
consent: { grantId: existGrant.id },
},
{
mergeWithLastSubmission: true,
}
);
});
it('should save first consented app id', async () => {
userQueries.findUserById.mockImplementationOnce(async () => ({
...mockUser,
applicationId: null,
}));
const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant);
await consent(context, provider, queries, baseInteractionDetails);
expect(userQueries.updateUserById).toHaveBeenCalledWith(mockUser.id, {
applicationId: baseInteractionDetails.params.client_id,
});
});
it('should grant missing scopes', async () => {
const interactionDetails = {
...baseInteractionDetails,
prompt: {
details: {
missingOIDCScope: ['openid', 'profile'],
missingResourceScopes: {
resource1: ['resource1_scope1', 'resource1_scope2'],
resource2: ['resource2_scope1'],
},
},
},
} as unknown as Interaction;
const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant);
await consent(context, provider, queries, interactionDetails);
expect(grantAddOIDCScope).toHaveBeenCalledWith('openid profile');
expect(grantAddResourceScope).toHaveBeenCalledWith(
'resource1',
'resource1_scope1 resource1_scope2'
);
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'resource2_scope1');
});
});

View file

@ -1,10 +1,13 @@
import { conditional } from '@silverhand/essentials';
import type { Context } from 'koa';
import type { InteractionResults } from 'oidc-provider';
import type Provider from 'oidc-provider';
import { z } from 'zod';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
export const assignInteractionResults = async (
const updateInteractionResult = async (
ctx: Context,
provider: Provider,
result: InteractionResults,
@ -16,7 +19,7 @@ export const assignInteractionResults = async (
// refer to: https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106
const details = merge ? await provider.interactionDetails(ctx.req, ctx.res) : undefined;
const redirectTo = await provider.interactionResult(
return provider.interactionResult(
ctx.req,
ctx.res,
{
@ -28,10 +31,20 @@ export const assignInteractionResults = async (
mergeWithLastSubmission: merge,
}
);
};
export const assignInteractionResults = async (
ctx: Context,
provider: Provider,
result: InteractionResults,
merge = false
) => {
const redirectTo = await updateInteractionResult(ctx, provider, result, merge);
ctx.body = { redirectTo };
};
export const saveUserFirstConsentedAppId = async (
const saveUserFirstConsentedAppId = async (
queries: Queries,
userId: string,
applicationId: string
@ -44,3 +57,49 @@ export const saveUserFirstConsentedAppId = async (
await updateUserById(userId, { applicationId });
}
};
export const consent = async (
ctx: Context,
provider: Provider,
queries: Queries,
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>
) => {
const {
session,
grantId,
params: { client_id },
prompt,
} = interactionDetails;
assertThat(session, 'session.not_found');
const { accountId } = session;
const grant =
conditional(grantId && (await provider.Grant.find(grantId))) ??
new provider.Grant({ accountId, clientId: String(client_id) });
await saveUserFirstConsentedAppId(queries, accountId, String(client_id));
// 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();
// Configure consent
return updateInteractionResult(ctx, provider, { consent: { grantId: finalGrantId } }, true);
};

View file

@ -0,0 +1,28 @@
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import type Provider from 'oidc-provider';
import { consent } from '#src/libraries/session.js';
import type Queries from '#src/tenants/Queries.js';
/**
* Automatically consent for the first party apps.
*/
export default function koaAutoConsent<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
provider: Provider,
query: Queries
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const shouldAutoConsent = true;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Update later. Third party app should not auto consent
if (!shouldAutoConsent) {
return next();
}
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const redirectTo = await consent(ctx, provider, query, interactionDetails);
ctx.redirect(redirectTo);
};
}

View file

@ -1,169 +0,0 @@
import type { User } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockUser } from '#src/__mocks__/index.js';
import type Queries from '#src/tenants/Queries.js';
import { createMockProvider, GrantMock } from '#src/test-utils/oidc-provider.js';
import type { Partial2 } from '#src/test-utils/tenant.js';
import { MockTenant } 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 userQueries = {
findUserById: jest.fn(async (): Promise<User> => mockUser),
updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })),
};
const { findUserById, updateUserById } = userQueries;
// @ts-expect-error
const queries: Partial2<Queries> = { users: userQueries };
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: new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant),
queries
),
});
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: new MockTenant(
createMockProvider(
jest.fn().mockResolvedValue({
...baseInteractionDetails,
grantId: 'exists',
}),
Grant
),
queries
),
});
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: new MockTenant(
createMockProvider(
jest.fn().mockResolvedValue({
...baseInteractionDetails,
prompt: {
name: 'consent',
details: {},
reasons: ['consent_prompt', 'native_client_prompt'],
},
}),
Grant
),
queries
),
});
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: new MockTenant(
createMockProvider(
jest.fn().mockResolvedValue({
...baseInteractionDetails,
prompt: {
details: {
missingOIDCScope: ['scope1', 'scope2'],
missingResourceScopes: {
resource1: ['scope1', 'scope2'],
resource2: ['scope3'],
},
},
},
}),
Grant
),
queries
),
});
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

@ -1,11 +1,8 @@
import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js';
import { consent } from '#src/libraries/session.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { interactionPrefix } from './const.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
@ -17,44 +14,9 @@ export default function consentRoutes<T extends IRouterParamContext>(
router.post(`${interactionPrefix}/consent`, async (ctx, next) => {
const { interactionDetails } = ctx;
const {
session,
grantId,
params: { client_id },
prompt,
} = interactionDetails;
const redirectTo = await consent(ctx, provider, queries, interactionDetails);
assertThat(session, 'session.not_found');
const { accountId } = session;
const grant =
conditional(grantId && (await provider.Grant.find(grantId))) ??
new provider.Grant({ accountId, clientId: String(client_id) });
await saveUserFirstConsentedAppId(queries, 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);
ctx.body = { redirectTo };
return next();
});

View file

@ -13,6 +13,7 @@ import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js';
import { createCloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { createConnectorLibrary } from '#src/libraries/connector.js';
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import koaAutoConsent from '#src/middleware/koa-auto-consent.js';
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
@ -23,6 +24,7 @@ import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import initOidc from '#src/oidc/init.js';
import { routes } from '#src/routes/consts.js';
import initApis from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
import BasicSentinel from '#src/sentinel/basic-sentinel.js';
@ -137,7 +139,13 @@ export default class Tenant implements TenantContext {
}
// Mount UI
app.use(compose([koaSpaSessionGuard(provider, queries), koaSpaProxy(mountedApps)]));
app.use(
compose([
koaSpaSessionGuard(provider, queries),
mount(`${routes.consent}`, koaAutoConsent(provider, queries)),
koaSpaProxy(mountedApps),
])
);
this.app = app;
this.provider = provider;

View file

@ -42,6 +42,8 @@ export const createMockProvider = (
interactionDetails ?? (async () => ({ params: {}, jti: 'jti', client_id: 'mockApplicationId' }))
);
jest.spyOn(provider, 'interactionResult').mockImplementation(async () => 'redirectTo');
if (Grant) {
Sinon.stub(provider, 'Grant').value(Grant);
}

View file

@ -6,7 +6,7 @@ import { assert } from '@silverhand/essentials';
import type { Got } from 'got';
import { got } from 'got';
import { consent, submitInteraction } from '#src/api/index.js';
import { submitInteraction } from '#src/api/index.js';
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';
import { MemoryStorage } from './storage.js';
@ -162,10 +162,19 @@ export default class MockClient {
assert(this.interactionCookie, new Error('Session not found'));
assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found'));
const { redirectTo } = await consent(this.api, this.interactionCookie);
const consentResponse = await got.get(`${this.config.endpoint}/consent`, {
headers: {
cookie: this.interactionCookie,
},
followRedirect: false,
});
// Note: should redirect to oidc auth endpoint
assert(redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`), new Error('Consent failed'));
// Consent page should auto consent and redirect to auth endpoint
const redirectTo = consentResponse.headers.location;
if (!redirectTo?.startsWith(`${this.config.endpoint}/oidc/auth`)) {
throw new Error('Consent failed');
}
const authCodeResponse = await got.get(redirectTo, {
headers: {