Skip to content
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
131 changes: 131 additions & 0 deletions packages/core/src/middleware/koa-auto-consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Provider } from 'oidc-provider';

import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import koaAutoConsent from './koa-auto-consent.js';

const { jest } = import.meta;
type InteractionDetails = Awaited<ReturnType<Provider['interactionDetails']>>;
type ApplicationQueries = Queries['applications'];
const prompt = {
name: 'consent',
reasons: [],
details: {},
} satisfies InteractionDetails['prompt'];

const createContext = (interactionDetails: Partial<InteractionDetails>, redirect = jest.fn()) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal interaction details stub for middleware testing
const details = {
params: {},
...interactionDetails,
} as InteractionDetails;

return {
...createContextWithRouteParameters({
url: '/consent',
redirect,
}),
interactionDetails: details,
};
};

const createTenant = ({
isThirdParty = false,
assertUserHasApplicationAccess = jest.fn(async () => {
await Promise.resolve();
}),
}: {
readonly isThirdParty?: boolean;
readonly assertUserHasApplicationAccess?: jest.Mock;
} = {}) => {
const findApplicationById = jest.fn(async () => ({
isThirdParty,
})) as unknown as ApplicationQueries['findApplicationById'];

return {
findApplicationById,
tenant: new MockTenant(
undefined,
{
applications: { findApplicationById },
},
undefined,
{
applicationAccessControl: { assertUserHasApplicationAccess },
}
),
};
};

describe('koaAutoConsent middleware', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should continue to the consent SPA when auto-consent application access is denied', async () => {
const accessDeniedError = new RequestError('oidc.access_denied');
const assertUserHasApplicationAccess = jest.fn(async () => {
throw accessDeniedError;
});
const { findApplicationById, tenant } = createTenant({ assertUserHasApplicationAccess });
const redirect = jest.fn();
const next = jest.fn();
const ctx = createContext(
{
params: { client_id: 'app-id' },
prompt,
// @ts-expect-error
session: { accountId: 'user-id' },
},
redirect
);
const guard = koaAutoConsent({} as Provider, tenant.queries, tenant.libraries);

await guard(ctx, next);

expect(findApplicationById).toHaveBeenCalledWith('app-id');
expect(assertUserHasApplicationAccess).toHaveBeenCalledWith('app-id', 'user-id');
expect(redirect).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

it('should not check application access for third-party applications', async () => {
const assertUserHasApplicationAccess = jest.fn();
const { tenant } = createTenant({ isThirdParty: true, assertUserHasApplicationAccess });
const next = jest.fn();
const ctx = createContext({
params: { client_id: 'app-id' },
prompt,
// @ts-expect-error
session: { accountId: 'user-id' },
});
const guard = koaAutoConsent({} as Provider, tenant.queries, tenant.libraries);

await guard(ctx, next);

expect(assertUserHasApplicationAccess).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

it('should throw non-access-denied application access errors', async () => {
const error = new RequestError({ code: 'request.invalid_input' });
const assertUserHasApplicationAccess = jest.fn(async () => {
throw error;
});
const { tenant } = createTenant({ assertUserHasApplicationAccess });
const next = jest.fn();
const ctx = createContext({
params: { client_id: 'app-id' },
prompt,
// @ts-expect-error
session: { accountId: 'user-id' },
});
const guard = koaAutoConsent({} as Provider, tenant.queries, tenant.libraries);

await expect(guard(ctx, next)).rejects.toBe(error);
expect(next).not.toHaveBeenCalled();
});
});
26 changes: 25 additions & 1 deletion packages/core/src/middleware/koa-auto-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { type MiddlewareType } from 'koa';
import type { Provider } from 'oidc-provider';
import { errors } from 'oidc-provider';

import RequestError from '#src/errors/RequestError/index.js';
import { consent, getMissingScopes } from '#src/libraries/session/index.js';
import type { WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

Expand All @@ -24,18 +26,27 @@ const shouldAutoConsentApplication = async (clientId: string, query: Queries) =>
return !application.isThirdParty;
};

const isApplicationAccessDeniedError = (error: unknown) =>
error instanceof RequestError && error.code === 'oidc.access_denied';

export default function koaAutoConsent<
StateT,
ContextT extends WithInteractionDetailsContext,
ResponseBodyT,
>(provider: Provider, query: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
>(
provider: Provider,
query: Queries,
libraries: Libraries
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const { interactionDetails } = ctx;
const {
params: { client_id: clientId },
prompt,
session,
} = interactionDetails;

assertThat(session, new RequestError({ code: 'session.not_found' }));
assertThat(
clientId && typeof clientId === 'string',
new errors.InvalidClient('client must be available')
Expand All @@ -44,6 +55,19 @@ export default function koaAutoConsent<
const shouldAutoConsent = await shouldAutoConsentApplication(clientId, query);

if (shouldAutoConsent) {
try {
await libraries.applicationAccessControl.assertUserHasApplicationAccess(
clientId,
session.accountId
);
} catch (error: unknown) {
if (isApplicationAccessDeniedError(error)) {
return next();
}

throw error;
}

const { missingOIDCScope: missingOIDCScopes, missingResourceScopes: resourceScopesToGrant } =
getMissingScopes(prompt);

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/routes/interaction/consent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function consentRoutes<T extends IRouterParamContext>(
body: z.object({
organizationIds: z.string().array().optional(),
}),
status: [200],
status: [200, 400],
}),
koaAppAccessControl(libraries),
async (ctx, next) => {
Expand Down Expand Up @@ -202,9 +202,10 @@ export default function consentRoutes<T extends IRouterParamContext>(
router.get(
consentPath,
koaGuard({
status: [200],
status: [200, 400],
response: consentInfoResponseGuard,
}),
koaAppAccessControl(libraries),
async (ctx, next) => {
const { interactionDetails } = ctx;

Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ 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 koaAppAccessControl from '#src/middleware/koa-app-access-control.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';
Expand Down Expand Up @@ -246,8 +245,7 @@ export default class Tenant implements TenantContext {
compose([
koaInteractionDetails(provider),
koaConsentGuard(libraries, queries),
koaAppAccessControl(libraries),
koaAutoConsent(provider, queries),
koaAutoConsent(provider, queries, libraries),
])
),
koaSpaProxy({ mountedApps, queries }),
Expand Down
110 changes: 110 additions & 0 deletions packages/experience/src/pages/Consent/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { ConsentInfoResponse, RequestErrorBody } from '@logto/schemas';
import { fireEvent, waitFor } from '@testing-library/react';
import { HTTPError } from 'ky';

import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { consent, getConsentInfo } from '@/apis/consent';

import Consent from '.';

jest.mock('@/apis/consent', () => ({
consent: jest.fn(),
getConsentInfo: jest.fn(),
}));

const mockedConsent = consent as jest.MockedFunction<typeof consent>;
const mockedGetConsentInfo = getConsentInfo as jest.MockedFunction<typeof getConsentInfo>;

const consentInfo: ConsentInfoResponse = {
application: {
id: 'application_id',
name: 'Application',
displayName: null,
privacyPolicyUrl: null,
termsOfUseUrl: null,
},
user: {
id: 'user_id',
name: null,
avatar: null,
username: 'user',
primaryEmail: 'user@example.com',
primaryPhone: null,
},
missingOIDCScope: [],
missingResourceScopes: [],
redirectUri: 'https://example.com/callback',
};

const createHttpError = (body: RequestErrorBody) =>
new HTTPError(
{
status: 400,
statusText: 'Bad Request',
json: async () => body,
clone: () => ({
json: async () => body,
}),
} as Response,
{} as Request,
{} as ConstructorParameters<typeof HTTPError>[2]
);

const accessDeniedError = () =>
createHttpError({
code: 'oidc.access_denied',
data: undefined,
message: 'Access denied.',
});

const renderConsent = () =>
renderWithPageContext(
<SettingsProvider>
<UserInteractionContextProvider>
<Consent />
</UserInteractionContextProvider>
</SettingsProvider>
);

describe('Consent', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders generic access denied page when consent info is denied', async () => {
mockedGetConsentInfo.mockRejectedValueOnce(accessDeniedError());

const { queryByText } = renderConsent();

await waitFor(() => {
expect(queryByText('error.access_denied')).not.toBeNull();
});

expect(queryByText('error.application_access_denied')).not.toBeNull();
expect(queryByText('action.authorize')).toBeNull();
expect(queryByText('action.cancel')).toBeNull();
});

it('renders generic access denied page when consent submission is denied', async () => {
mockedGetConsentInfo.mockResolvedValueOnce(consentInfo);
mockedConsent.mockRejectedValueOnce(accessDeniedError());

const { getByText, queryByText } = renderConsent();

await waitFor(() => {
expect(queryByText('action.authorize')).not.toBeNull();
});

fireEvent.click(getByText('action.authorize'));

await waitFor(() => {
expect(queryByText('error.access_denied')).not.toBeNull();
});

expect(queryByText('error.application_access_denied')).not.toBeNull();
expect(queryByText('action.authorize')).toBeNull();
expect(queryByText('action.cancel')).toBeNull();
});
});
Loading
Loading