Skip to content

Commit

Permalink
feat(core,schemas): add org resource scopes to consent get (#5808)
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie authored May 7, 2024
1 parent f57e21f commit 726a65d
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 56 deletions.
13 changes: 8 additions & 5 deletions packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,18 @@ export const createUserLibrary = (queries: Queries) => {
const findUserScopesForResourceIndicator = async (
userId: string,
resourceIndicator: string,
findFromOrganizations = false,
organizationId?: string
): Promise<readonly Scope[]> => {
const usersRoles = await findUsersRolesByUserId(userId);
const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId));
const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes(
userId,
resourceIndicator,
organizationId
);
const organizationScopes = findFromOrganizations
? await organizations.relations.rolesUsers.getUserResourceScopes(
userId,
resourceIndicator,
organizationId
)
: [];

const scopes = await findScopesByIdsAndResourceIndicator(
[...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)],
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/oidc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ export default function initOidc(

const { accessTokenTtl: accessTokenTTL } = resourceServer;

const { client, params } = ctx.oidc;
const { client, params, session, entities } = ctx.oidc;
const userId = session?.accountId ?? entities.Account?.accountId;

/**
* In consent or code exchange flow, the organization_id is undefined,
* and all the scopes inherited from the all organization roles will be granted.
Expand All @@ -152,16 +154,19 @@ export default function initOidc(
* and will then narrow down the scopes to the specific organization.
*/
const organizationId = params?.organization_id;
const scopes = await findResourceScopes(
const scopes = await findResourceScopes({
queries,
libraries,
ctx,
indicator,
typeof organizationId === 'string' ? organizationId : undefined
);
findFromOrganizations: true,
organizationId: typeof organizationId === 'string' ? organizationId : undefined,
applicationId: client?.clientId,
userId,
});

// Need to filter out the unsupported scopes for the third-party application.
if (client && (await isThirdPartyApplication(queries, client.clientId))) {
// Get application consent resource scopes, from RBAC roles
const filteredScopes = await filterResourceScopesForTheThirdPartyApplication(
libraries,
client.clientId,
Expand Down
62 changes: 39 additions & 23 deletions packages/core/src/oidc/resource.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ReservedResource } from '@logto/core-kit';
import { type Resource } from '@logto/schemas';
import { trySafe, type Nullable } from '@silverhand/essentials';
import { type ResourceServer, type KoaContextWithOIDC } from 'oidc-provider';
import { type ResourceServer } from 'oidc-provider';

import { type EnvSet } from '#src/env-set/index.js';
import { EnvSet } from '#src/env-set/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';

Expand All @@ -28,13 +28,23 @@ export const getSharedResourceServerData = (
*
* @see {@link ReservedResource} for the list of reserved resources.
*/
export const findResourceScopes = async (
queries: Queries,
libraries: Libraries,
ctx: KoaContextWithOIDC,
indicator: string,
organizationId?: string
): Promise<ReadonlyArray<{ name: string; id: string }>> => {
export const findResourceScopes = async ({
queries,
libraries,
userId,
applicationId,
indicator,
organizationId,
findFromOrganizations,
}: {
queries: Queries;
libraries: Libraries;
indicator: string;
findFromOrganizations: boolean;
userId?: string;
applicationId?: string;
organizationId?: string;
}): Promise<ReadonlyArray<{ name: string; id: string }>> => {
if (isReservedResource(indicator)) {
switch (indicator) {
case ReservedResource.Organization: {
Expand All @@ -44,21 +54,22 @@ export const findResourceScopes = async (
}
}

const { oidc } = ctx;
const {
users: { findUserScopesForResourceIndicator },
applications: { findApplicationScopesForResourceIndicator },
} = libraries;
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;

if (userId) {
return findUserScopesForResourceIndicator(userId, indicator, organizationId);
return findUserScopesForResourceIndicator(
userId,
indicator,
findFromOrganizations,
organizationId
);
}

const clientId = oidc.entities.Client?.clientId;

if (clientId) {
return findApplicationScopesForResourceIndicator(clientId, indicator);
if (applicationId) {
return findApplicationScopesForResourceIndicator(applicationId, indicator);
}

return [];
Expand Down Expand Up @@ -115,6 +126,7 @@ export const filterResourceScopesForTheThirdPartyApplication = async (
applications: {
getApplicationUserConsentOrganizationScopes,
getApplicationUserConsentResourceScopes,
getApplicationUserConsentOrganizationResourceScopes,
},
} = libraries;

Expand Down Expand Up @@ -146,16 +158,20 @@ export const filterResourceScopesForTheThirdPartyApplication = async (
const userConsentResource = userConsentResources.find(
({ resource }) => resource.indicator === indicator
);
const userConsentOrganizationResources = EnvSet.values.isDevFeaturesEnabled
? await getApplicationUserConsentOrganizationResourceScopes(applicationId)
: [];
const userConsentOrganizationResource = userConsentOrganizationResources.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;
const resourceScopes = [
...(userConsentResource?.scopes ?? []),
...(userConsentOrganizationResource?.scopes ?? []),
];

return scopes.filter(({ id: resourceScopeId }) =>
userConsentResourceScopes.some(
resourceScopes.some(
({ id: consentResourceScopeId }) => consentResourceScopeId === resourceScopeId
)
);
Expand Down
105 changes: 90 additions & 15 deletions packages/core/src/routes/interaction/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
publicApplicationGuard,
publicUserInfoGuard,
applicationSignInExperienceGuard,
publicOrganizationGuard,
missingResourceScopesGuard,
type ConsentInfoResponse,
type MissingResourceScopes,
Expand All @@ -16,8 +15,10 @@ import { type IRouterParamContext } from 'koa-router';
import { errors } from 'oidc-provider';
import { z } from 'zod';

import { EnvSet } from '#src/env-set/index.js';
import { consent, getMissingScopes } from '#src/libraries/session.js';
import koaGuard from '#src/middleware/koa-guard.js';
import { findResourceScopes } from '#src/oidc/resource.js';
import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
Expand Down Expand Up @@ -96,16 +97,63 @@ const parseMissingResourceScopesInfo = async (
);
};

/**
* The missingResourceScopes in the prompt details are from `getResourceServerInfo`,
* which contains resource scopes and organization resource scopes.
* We need to separate the organization resource scopes from the resource scopes.
* The "scopes" in `missingResourceScopes` do not have "id", so we have to rebuild the scopes list first.
*/
const filterAndParseMissingResourceScopes = async ({
resourceScopes,
queries,
libraries,
userId,
organizationId,
}: {
resourceScopes: Record<string, string[]>;
queries: Queries;
libraries: TenantContext['libraries'];
userId: string;
organizationId?: string;
}) => {
const filteredResourceScopes = Object.fromEntries(
await Promise.all(
Object.entries(resourceScopes).map(
async ([resourceIndicator, missingScopes]): Promise<[string, string[]]> => {
if (!EnvSet.values.isDevFeaturesEnabled) {
return [resourceIndicator, missingScopes];
}

// Fetch the list of scopes, `findFromOrganizations` is set to false,
// so it will only search the user resource scopes.
const scopes = await findResourceScopes({
queries,
libraries,
indicator: resourceIndicator,
userId,
findFromOrganizations: Boolean(organizationId),
organizationId,
});

return [
resourceIndicator,
missingScopes.filter((scope) => scopes.some(({ name }) => name === scope)),
];
}
)
)
);

return parseMissingResourceScopesInfo(queries, filteredResourceScopes);
};

export default function consentRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
{
provider,
queries,
libraries: {
applications: { validateUserConsentOrganizationMembership },
},
}: TenantContext
{ provider, queries, libraries }: TenantContext
) {
const {
applications: { validateUserConsentOrganizationMembership },
} = libraries;
const consentPath = `${interactionPrefix}/consent`;

router.post(
Expand Down Expand Up @@ -201,12 +249,42 @@ export default function consentRoutes<T extends IRouterParamContext>(

const userInfo = await queries.users.findUserById(accountId);

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

// The missingResourceScopes from the prompt details are from `getResourceServerInfo`,
// which contains resource scopes and organization resource scopes.
// We need to separate the organization resource scopes from the resource scopes.
// The "scopes" in `missingResourceScopes` do not have "id", so we have to rebuild the scopes list.
const missingResourceScopes = await filterAndParseMissingResourceScopes({
resourceScopes: allMissingResourceScopes,
queries,
libraries,
userId: accountId,
});

// Find the organizations if the application is requesting the organizations scope
const organizations = missingOIDCScope?.includes(UserScope.Organizations)
? await queries.organizations.relations.users.getOrganizationsByUserId(accountId)
: undefined;
: [];

const organizationsWithMissingResourceScopes = await Promise.all(
organizations.map(async ({ name, id }) => {
if (!EnvSet.values.isDevFeaturesEnabled) {
return { name, id };
}

const missingResourceScopes = await filterAndParseMissingResourceScopes({
resourceScopes: allMissingResourceScopes,
queries,
libraries,
userId: accountId,
organizationId: id,
});

return { name, id, missingResourceScopes };
})
);

ctx.body = {
// Merge the public application data and application sign-in-experience data
Expand All @@ -218,15 +296,12 @@ export default function consentRoutes<T extends IRouterParamContext>(
),
},
user: publicUserInfoGuard.parse(userInfo),
organizations: organizations?.map((organization) =>
publicOrganizationGuard.parse(organization)
),
organizations: organizationsWithMissingResourceScopes,
// Filter out the OIDC scopes that are not needed for the consent page.
missingOIDCScope: missingOIDCScope?.filter(
(scope) => scope !== 'openid' && scope !== 'offline_access'
),
// Parse the missing resource scopes info with details.
missingResourceScopes: await parseMissingResourceScopesInfo(queries, missingResourceScopes),
missingResourceScopes,
redirectUri,
} satisfies ConsentInfoResponse;

Expand Down
Loading

0 comments on commit 726a65d

Please sign in to comment.