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:
parent
6f8c4c1f1b
commit
94908ee8ce
7 changed files with 233 additions and 31 deletions
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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(' ');
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue