0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(core): add third-party application scopes and organizations restriction (#5249)

* feat(core): add client scope restriction metadata for third-party apps

add client scope restriction metadata for third-party apps

* feat(core): disable auto consent for the thrid-party apps

disable auto consent for the third-party apps

* feat(core): filter out not enabled resource scopes for third-party app

filter out not enabled resource scopes for third-party app

* feat(core): add organization grant validation for third-party application refresh_token grant

add organization grant validation for third-party application refresh_token grant

* fix(core): remove the resource scopes from client metadata

remove the resource scopes from client metadata
This commit is contained in:
simeng-li 2024-01-19 10:31:26 +08:00 committed by GitHub
parent 6f8c4c1f1b
commit 94908ee8ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 233 additions and 31 deletions

View file

@ -1,3 +1,4 @@
import { trySafe } from '@silverhand/essentials';
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import type Provider from 'oidc-provider';
@ -13,14 +14,19 @@ export default function koaAutoConsent<StateT, ContextT extends IRouterParamCont
query: Queries
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const shouldAutoConsent = true;
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const { client_id: clientId } = interactionDetails.params;
const application = await trySafe(async () =>
query.applications.findApplicationById(String(clientId))
);
const shouldAutoConsent = !application?.isThirdParty;
// 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,6 +1,6 @@
import type { CreateApplication } from '@logto/schemas';
import { ApplicationType, adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas';
import { appendPath, tryThat } from '@silverhand/essentials';
import { appendPath, tryThat, conditional } from '@silverhand/essentials';
import { addSeconds } from 'date-fns';
import type { AdapterFactory, AllClientMetadata } from 'oidc-provider';
import { errors } from 'oidc-provider';
@ -22,6 +22,7 @@ const transpileMetadata = (clientId: string, data: AllClientMetadata): AllClient
}
const { adminUrlSet, cloudUrlSet } = EnvSet.values;
const urls = [
...adminUrlSet.deduplicated().map((url) => appendPath(url, '/console')),
...cloudUrlSet.deduplicated(),
@ -51,12 +52,44 @@ const buildDemoAppClientMetadata = (envSet: EnvSet): AllClientMetadata => {
};
};
/**
* Restrict third-party client OP scopes to the app-level enabled user claims scopes
*
* client OP scopes include:
* - OIDC scopes: openid, offline_access
* - custom scopes: @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#scopes}
* - scopes defined in user claims: @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#claims}
*
* @remark
* We use the client metadata scope metadata to restrict the third-party client scopes,
*
* - @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#clients}
* - client metadata scope must be a valid OP scope, otherwise a invalid metadata error will be thrown. @see{@link https://github.com/panva/node-oidc-provider/blob/main/lib/helpers/client_schema.js#L626}
* - resource scopes (including Logto organization scopes) are not include in the OP scope, it won't be validate by the client metadata scope as well. @see {@link https://github.com/panva/node-oidc-provider/blob/main/lib/actions/authorization/check_scope.js#L47}
* - resource scopes (including Logto organization scopes) will be filtered in the resource server's scopes fetching method. @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#getresourceserverinfo}
*
* Auth request will be rejected if the requested scopes are not included in the client scope metadata.
*/
const getThirdPartyClientScopes = async (
{ userConsentUserScopes }: Queries['applications'],
applicationId: string
) => {
const availableUserScopes = await userConsentUserScopes.findAllByApplicationId(applicationId);
const clientScopes = ['openid', 'offline_access', ...availableUserScopes];
// ClientScopes does not support prefix matching, so we need to include all the scopes.
// Resource scopes name are not unique, we need to deduplicate them.
// Requested resource scopes and organization scopes will be validated in resource server fetching method exclusively.
return clientScopes;
};
export default function postgresAdapter(
envSet: EnvSet,
queries: Queries,
modelName: string
): ReturnType<AdapterFactory> {
const {
applications,
applications: { findApplicationById },
oidcModelInstances: {
consumeInstanceById,
@ -72,14 +105,17 @@ export default function postgresAdapter(
const reject = async () => {
throw new Error('Not implemented');
};
const transpileClient = ({
id: client_id,
secret: client_secret,
name: client_name,
type,
oidcClientMetadata,
customClientMetadata,
}: CreateApplication): AllClientMetadata => ({
const transpileClient = (
{
id: client_id,
secret: client_secret,
name: client_name,
type,
oidcClientMetadata,
customClientMetadata,
}: CreateApplication,
clientScopes?: string[]
): AllClientMetadata => ({
client_id,
client_secret,
client_name,
@ -87,6 +123,8 @@ export default function postgresAdapter(
...transpileMetadata(client_id, snakecaseKeys(oidcClientMetadata)),
// `node-oidc-provider` won't camelCase custom parameter keys, so we need to keep the keys camelCased
...customClientMetadata,
/* Third-party client scopes are restricted to the app-level enabled user scopes. */
...conditional(clientScopes && { scope: clientScopes.join(' ') }),
});
return {
@ -96,9 +134,18 @@ export default function postgresAdapter(
return buildDemoAppClientMetadata(envSet);
}
return transpileClient(
await tryThat(findApplicationById(id), new errors.InvalidClient(`invalid client ${id}`))
const application = await tryThat(
findApplicationById(id),
new errors.InvalidClient(`invalid client ${id}`)
);
// FIXME: @simeng-li Remove this check after the third-party feature is released
if (EnvSet.values.isDevFeaturesEnabled && application.isThirdParty) {
const clientScopes = await getThirdPartyClientScopes(applications, id);
return transpileClient(application, clientScopes);
}
return transpileClient(application);
},
findByUserCode: reject,
findByUid: reject,

View file

@ -21,7 +21,7 @@ export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries)
// Override the default `refresh_token` grant
oidc.registerGrantType(
GrantType.RefreshToken,
refreshToken.buildHandler(envSet, queries.organizations),
refreshToken.buildHandler(envSet, queries),
...parameterConfig
);
};

View file

@ -2,6 +2,7 @@ import { UserScope } from '@logto/core-kit';
import { type KoaContextWithOIDC, errors, type Adapter } from 'oidc-provider';
import Sinon from 'sinon';
import { mockApplication } from '#src/__mocks__/index.js';
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';
@ -13,7 +14,7 @@ const { jest } = import.meta;
const noop = async () => {};
const mockHandler = (tenant = new MockTenant()) => {
return buildHandler(tenant.envSet, tenant.queries.organizations);
return buildHandler(tenant.envSet, tenant.queries);
};
const clientId = 'some_client_id';
@ -283,6 +284,18 @@ describe('organization token grant', () => {
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.AccessDenied);
});
it('should throw if the user has not granted the requested organization', async () => {
const ctx = createPreparedContext();
const tenant = new MockTenant();
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves({
...mockApplication,
isThirdParty: true,
});
Sinon.stub(tenant.queries.applications.userConsentOrganizations, 'exists').resolves(false);
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.AccessDenied);
});
// The handler returns void so we cannot check the return value, and it's also not
// straightforward to assert the token is issued correctly. Here we just do the sanity
// check and basic token validation. Comprehensive token validation should be done in
@ -292,6 +305,7 @@ describe('organization token grant', () => {
const tenant = new MockTenant();
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([
{ id: 'foo', name: 'foo' },
{ id: 'bar', name: 'bar' },

View file

@ -33,11 +33,16 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js';
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
import { type EnvSet } from '#src/env-set/index.js';
import type OrganizationQueries from '#src/queries/organization/index.js';
import { EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
import {
getSharedResourceServerData,
isThirdPartyApplication,
reversedResourceAccessTokenTtl,
isOrganizationConsentedToApplication,
} from '../resource.js';
const {
InvalidClient,
@ -71,7 +76,7 @@ const requiredParameters = Object.freeze(['refresh_token'] as const) satisfies R
/* eslint-disable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
export const buildHandler: (
envSet: EnvSet,
queries: OrganizationQueries
queries: Queries
// eslint-disable-next-line complexity
) => Parameters<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
const { client, params, requestParamScopes, provider } = ctx.oidc;
@ -220,12 +225,27 @@ export const buildHandler: (
}
/* === RFC 0001 === */
// Check membership
if (
organizationId &&
!(await queries.relations.users.exists(organizationId, account.accountId))
) {
throw new AccessDenied('user is not a member of the organization');
if (organizationId) {
// Check membership
if (!(await queries.organizations.relations.users.exists(organizationId, account.accountId))) {
throw new AccessDenied('user is not a member of the organization');
}
// Check if the organization is granted (third-party application only) by the user
// FIXME @simeng-li: remove the `isDevFeaturesEnabled` check when the feature is enabled
if (
EnvSet.values.isDevFeaturesEnabled &&
(await isThirdPartyApplication(queries, client.clientId)) &&
!(await isOrganizationConsentedToApplication(
queries,
client.clientId,
account.accountId,
organizationId
))
) {
throw new AccessDenied('organization access is not granted to the application');
}
}
/* === End RFC 0001 === */
@ -299,9 +319,10 @@ export const buildHandler: (
if (organizationId) {
const audience = buildOrganizationUrn(organizationId);
/** All available scopes for the user in the organization. */
const availableScopes = await queries.relations.rolesUsers
const availableScopes = await queries.organizations.relations.rolesUsers
.getUserScopes(organizationId, account.accountId)
.then((scopes) => scopes.map(({ name }) => name));
/** The intersection of the available scopes and the requested scopes. */
const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' ');

View file

@ -19,7 +19,7 @@ import koaBody from 'koa-body';
import Provider, { errors } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import { type EnvSet } from '#src/env-set/index.js';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
@ -32,7 +32,13 @@ import type Queries from '#src/tenants/Queries.js';
import defaults from './defaults.js';
import { registerGrants } from './grants/index.js';
import { findResource, findResourceScopes, getSharedResourceServerData } from './resource.js';
import {
findResource,
findResourceScopes,
getSharedResourceServerData,
isThirdPartyApplication,
filterResourceScopesForTheThirdPartyApplication,
} from './resource.js';
import { getAcceptedUserClaims, getUserClaimsData } from './scope.js';
import { OIDCExtraParametersKey, InteractionMode } from './type.js';
@ -119,7 +125,31 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
}
const { accessTokenTtl: accessTokenTTL } = resourceServer;
const scopes = await findResourceScopes(queries, libraries, ctx, indicator);
const { client } = ctx.oidc;
// FIXME: @simeng-li Remove this check after the third-party client scope feature is released
// Need to filter out the unsupported scopes for the third-party application.
if (
EnvSet.values.isDevFeaturesEnabled &&
client &&
(await isThirdPartyApplication(queries, client.clientId))
) {
const filteredScopes = await filterResourceScopesForTheThirdPartyApplication(
libraries,
client.clientId,
indicator,
scopes
);
return {
...getSharedResourceServerData(envSet),
accessTokenTTL,
scope: filteredScopes.map(({ name }) => name).join(' '),
};
}
return {
...getSharedResourceServerData(envSet),
accessTokenTTL,

View file

@ -1,6 +1,6 @@
import { ReservedResource } from '@logto/core-kit';
import { type Resource } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';
import { trySafe, type Nullable } from '@silverhand/essentials';
import { type KoaContextWithOIDC } from 'oidc-provider';
import type Provider from 'oidc-provider';
@ -34,7 +34,7 @@ export const findResourceScopes = async (
libraries: Libraries,
ctx: KoaContextWithOIDC,
indicator: string
): Promise<ReadonlyArray<{ name: string }>> => {
): Promise<ReadonlyArray<{ name: string; id: string }>> => {
if (isReservedResource(indicator)) {
switch (indicator) {
case ReservedResource.Organization: {
@ -93,3 +93,87 @@ export const findResource = async (
return queries.resources.findResourceByIndicator(indicator);
};
export const isThirdPartyApplication = async ({ applications }: Queries, applicationId: string) => {
// Demo-app not exist in the database
const application = await trySafe(async () => applications.findApplicationById(applicationId));
return application?.isThirdParty ?? false;
};
/**
* Filter out the unsupported scopes for the third-party application.
*
* third-party application can only request the scopes that are enabled in the client scope metadata @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#clients}
* However, the client scope metadata does not support prefix matching and resource scopes name are not unique, so we need to filter out the resource and organization scopes specifically based on the resource indicator.
*
* Available resource scopes can be found using {@link findResourceScopes}.
*/
export const filterResourceScopesForTheThirdPartyApplication = async (
libraries: Libraries,
applicationId: string,
indicator: string,
scopes: ReadonlyArray<{ name: string; id: string }>
) => {
const {
applications: {
getApplicationUserConsentOrganizationScopes,
getApplicationUserConsentResourceScopes,
},
} = libraries;
if (isReservedResource(indicator)) {
switch (indicator) {
case ReservedResource.Organization: {
const userConsentOrganizationScopes = await getApplicationUserConsentOrganizationScopes(
applicationId
);
// Filter out the organization scopes that are not enabled in the application
return scopes.filter(({ id: organizationScopeId }) =>
userConsentOrganizationScopes.some(
({ id: consentOrganizationId }) => consentOrganizationId === organizationScopeId
)
);
}
// Return all the scopes for the reserved resources
default: {
return scopes;
}
}
}
// Get the API resource scopes that are enabled in the application
const userConsentResources = await getApplicationUserConsentResourceScopes(applicationId);
const userConsentResource = userConsentResources.find(
({ resource }) => resource.indicator === indicator
);
// If the resource is not in the application enabled user consent resources, return empty array
if (!userConsentResource) {
return [];
}
const { scopes: userConsentResourceScopes } = userConsentResource;
return scopes.filter(({ id: resourceScopeId }) =>
userConsentResourceScopes.some(
({ id: consentResourceScopeId }) => consentResourceScopeId === resourceScopeId
)
);
};
/**
* Check if the user has consented to the application for the specific organization.
*
* User will be asked to grant the organization access to the application on the consent page.
* User application organization grant status can be managed using management API.
*/
export const isOrganizationConsentedToApplication = async (
{ applications: { userConsentOrganizations } }: Queries,
applicationId: string,
accountId: string,
organizationId: string
) => {
return userConsentOrganizations.exists(applicationId, accountId, organizationId);
};