diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index b6ca2020652..99070a8df7e 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -175,15 +175,18 @@ export const createUserLibrary = (queries: Queries) => { const findUserScopesForResourceIndicator = async ( userId: string, resourceIndicator: string, + findFromOrganizations = false, organizationId?: string ): Promise => { 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)], diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 769985d1587..14e0ef15d56 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -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. @@ -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, diff --git a/packages/core/src/oidc/resource.ts b/packages/core/src/oidc/resource.ts index 071a6c5e21c..91f3f5b5b00 100644 --- a/packages/core/src/oidc/resource.ts +++ b/packages/core/src/oidc/resource.ts @@ -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'; @@ -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> => { +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> => { if (isReservedResource(indicator)) { switch (indicator) { case ReservedResource.Organization: { @@ -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 []; @@ -115,6 +126,7 @@ export const filterResourceScopesForTheThirdPartyApplication = async ( applications: { getApplicationUserConsentOrganizationScopes, getApplicationUserConsentResourceScopes, + getApplicationUserConsentOrganizationResourceScopes, }, } = libraries; @@ -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 ) ); diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index 8c35f9079ca..1bcea6bc2d0 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -4,7 +4,6 @@ import { publicApplicationGuard, publicUserInfoGuard, applicationSignInExperienceGuard, - publicOrganizationGuard, missingResourceScopesGuard, type ConsentInfoResponse, type MissingResourceScopes, @@ -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'; @@ -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; + 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( router: Router>, - { - provider, - queries, - libraries: { - applications: { validateUserConsentOrganizationMembership }, - }, - }: TenantContext + { provider, queries, libraries }: TenantContext ) { + const { + applications: { validateUserConsentOrganizationMembership }, + } = libraries; const consentPath = `${interactionPrefix}/consent`; router.post( @@ -201,12 +249,42 @@ export default function consentRoutes( 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 @@ -218,15 +296,12 @@ export default function consentRoutes( ), }, 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; diff --git a/packages/integration-tests/src/tests/api/interaction/third-party-sign-in/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/third-party-sign-in/happy-path.test.ts index 44d998e3455..7aab62fc200 100644 --- a/packages/integration-tests/src/tests/api/interaction/third-party-sign-in/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/third-party-sign-in/happy-path.test.ts @@ -7,9 +7,18 @@ import { assignUserConsentScopes } from '#src/api/application-user-consent-scope import { createApplication, deleteApplication } from '#src/api/application.js'; import { getConsentInfo, putInteraction } from '#src/api/interaction.js'; import { OrganizationScopeApi } from '#src/api/organization-scope.js'; +import { createResource, deleteResource } from '#src/api/resource.js'; +import { createScope } from '#src/api/scope.js'; import { initClient } from '#src/helpers/client.js'; +import { OrganizationApiTest, OrganizationRoleApiTest } from '#src/helpers/organization.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; import { generateNewUser } from '#src/helpers/user.js'; +import { + generateResourceIndicator, + generateResourceName, + generateRoleName, + generateScopeName, +} from '#src/utils.js'; describe('consent api', () => { const applications = new Map(); @@ -126,6 +135,63 @@ describe('consent api', () => { await deleteUser(user.id); }); + it('get consent info with organization resource scopes', async () => { + const application = applications.get(thirdPartyApplicationName); + assert(application, new Error('application.not_found')); + + const resource = await createResource(generateResourceName(), generateResourceIndicator()); + const scope = await createScope(resource.id, generateScopeName()); + const scope2 = await createScope(resource.id, generateScopeName()); + const roleApi = new OrganizationRoleApiTest(); + const role = await roleApi.create({ + name: generateRoleName(), + resourceScopeIds: [scope.id], + }); + const organizationApi = new OrganizationApiTest(); + const organization = await organizationApi.create({ name: 'test_org' }); + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + await organizationApi.addUsers(organization.id, [user.id]); + await organizationApi.addUserRoles(organization.id, user.id, [role.id]); + + await assignUserConsentScopes(application.id, { + organizationResourceScopes: [scope.id], + userScopes: [UserScope.Organizations], + }); + + const client = await initClient( + { + appId: application.id, + appSecret: application.secret, + scopes: [UserScope.Organizations, UserScope.Profile, scope.name, scope2.name], + resources: [resource.indicator], + }, + redirectUri + ); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + username: userProfile.username, + password: userProfile.password, + }, + }); + + const { redirectTo } = await client.submitInteraction(); + + await client.processSession(redirectTo, false); + + const result = await client.send(getConsentInfo); + + expect(result.missingResourceScopes).toHaveLength(0); + // Only scope1, scope2 is removed + expect(result.organizations?.[0]?.missingResourceScopes).toHaveLength(1); + + await roleApi.cleanUp(); + await organizationApi.cleanUp(); + await deleteResource(resource.id); + await deleteUser(user.id); + }); + afterAll(async () => { for (const application of applications.values()) { void deleteApplication(application.id); diff --git a/packages/schemas/src/types/consent.ts b/packages/schemas/src/types/consent.ts index d1e58468df9..15e8b064936 100644 --- a/packages/schemas/src/types/consent.ts +++ b/packages/schemas/src/types/consent.ts @@ -37,14 +37,6 @@ export const applicationSignInExperienceGuard = ApplicationSignInExperiences.gua termsOfUseUrl: true, }); -/** - * Define the public organization info that can be exposed to the public. e.g. on the user consent page. - */ -export const publicOrganizationGuard = Organizations.guard.pick({ - id: true, - name: true, -}); - export const missingResourceScopesGuard = z.object({ // The original resource id has a maximum length of 21 restriction. We need to make it compatible with the logto reserved organization name. // use string here, as we do not care about the resource id length here. @@ -57,6 +49,20 @@ export const missingResourceScopesGuard = z.object({ */ export type MissingResourceScopes = z.infer; +/** + * Define the public organization info that can be exposed to the public. e.g. on the user consent page. + */ +export const publicOrganizationGuard = Organizations.guard + .pick({ + id: true, + name: true, + }) + .extend({ + missingResourceScopes: missingResourceScopesGuard.array().optional(), + }); + +export type PublicOrganization = z.infer; + export const consentInfoResponseGuard = z.object({ application: publicApplicationGuard.merge(applicationSignInExperienceGuard.partial()), user: publicUserInfoGuard,