0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,schemas): add org resource scopes to consent get (#5808)

This commit is contained in:
wangsijie 2024-05-07 16:39:37 +08:00 committed by GitHub
parent f57e21fc20
commit 726a65dd8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 227 additions and 56 deletions

View file

@ -175,15 +175,18 @@ export const createUserLibrary = (queries: Queries) => {
const findUserScopesForResourceIndicator = async ( const findUserScopesForResourceIndicator = async (
userId: string, userId: string,
resourceIndicator: string, resourceIndicator: string,
findFromOrganizations = false,
organizationId?: string organizationId?: string
): Promise<readonly Scope[]> => { ): Promise<readonly Scope[]> => {
const usersRoles = await findUsersRolesByUserId(userId); const usersRoles = await findUsersRolesByUserId(userId);
const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId)); const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId));
const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes( const organizationScopes = findFromOrganizations
userId, ? await organizations.relations.rolesUsers.getUserResourceScopes(
resourceIndicator, userId,
organizationId resourceIndicator,
); organizationId
)
: [];
const scopes = await findScopesByIdsAndResourceIndicator( const scopes = await findScopesByIdsAndResourceIndicator(
[...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)], [...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)],

View file

@ -143,7 +143,9 @@ export default function initOidc(
const { accessTokenTtl: accessTokenTTL } = resourceServer; 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, * In consent or code exchange flow, the organization_id is undefined,
* and all the scopes inherited from the all organization roles will be granted. * 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. * and will then narrow down the scopes to the specific organization.
*/ */
const organizationId = params?.organization_id; const organizationId = params?.organization_id;
const scopes = await findResourceScopes( const scopes = await findResourceScopes({
queries, queries,
libraries, libraries,
ctx,
indicator, 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. // Need to filter out the unsupported scopes for the third-party application.
if (client && (await isThirdPartyApplication(queries, client.clientId))) { if (client && (await isThirdPartyApplication(queries, client.clientId))) {
// Get application consent resource scopes, from RBAC roles
const filteredScopes = await filterResourceScopesForTheThirdPartyApplication( const filteredScopes = await filterResourceScopesForTheThirdPartyApplication(
libraries, libraries,
client.clientId, client.clientId,

View file

@ -1,9 +1,9 @@
import { ReservedResource } from '@logto/core-kit'; import { ReservedResource } from '@logto/core-kit';
import { type Resource } from '@logto/schemas'; import { type Resource } from '@logto/schemas';
import { trySafe, type Nullable } from '@silverhand/essentials'; 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 Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.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. * @see {@link ReservedResource} for the list of reserved resources.
*/ */
export const findResourceScopes = async ( export const findResourceScopes = async ({
queries: Queries, queries,
libraries: Libraries, libraries,
ctx: KoaContextWithOIDC, userId,
indicator: string, applicationId,
organizationId?: string indicator,
): Promise<ReadonlyArray<{ name: string; id: string }>> => { 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)) { if (isReservedResource(indicator)) {
switch (indicator) { switch (indicator) {
case ReservedResource.Organization: { case ReservedResource.Organization: {
@ -44,21 +54,22 @@ export const findResourceScopes = async (
} }
} }
const { oidc } = ctx;
const { const {
users: { findUserScopesForResourceIndicator }, users: { findUserScopesForResourceIndicator },
applications: { findApplicationScopesForResourceIndicator }, applications: { findApplicationScopesForResourceIndicator },
} = libraries; } = libraries;
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;
if (userId) { if (userId) {
return findUserScopesForResourceIndicator(userId, indicator, organizationId); return findUserScopesForResourceIndicator(
userId,
indicator,
findFromOrganizations,
organizationId
);
} }
const clientId = oidc.entities.Client?.clientId; if (applicationId) {
return findApplicationScopesForResourceIndicator(applicationId, indicator);
if (clientId) {
return findApplicationScopesForResourceIndicator(clientId, indicator);
} }
return []; return [];
@ -115,6 +126,7 @@ export const filterResourceScopesForTheThirdPartyApplication = async (
applications: { applications: {
getApplicationUserConsentOrganizationScopes, getApplicationUserConsentOrganizationScopes,
getApplicationUserConsentResourceScopes, getApplicationUserConsentResourceScopes,
getApplicationUserConsentOrganizationResourceScopes,
}, },
} = libraries; } = libraries;
@ -146,16 +158,20 @@ export const filterResourceScopesForTheThirdPartyApplication = async (
const userConsentResource = userConsentResources.find( const userConsentResource = userConsentResources.find(
({ resource }) => resource.indicator === indicator ({ 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 const resourceScopes = [
if (!userConsentResource) { ...(userConsentResource?.scopes ?? []),
return []; ...(userConsentOrganizationResource?.scopes ?? []),
} ];
const { scopes: userConsentResourceScopes } = userConsentResource;
return scopes.filter(({ id: resourceScopeId }) => return scopes.filter(({ id: resourceScopeId }) =>
userConsentResourceScopes.some( resourceScopes.some(
({ id: consentResourceScopeId }) => consentResourceScopeId === resourceScopeId ({ id: consentResourceScopeId }) => consentResourceScopeId === resourceScopeId
) )
); );

View file

@ -4,7 +4,6 @@ import {
publicApplicationGuard, publicApplicationGuard,
publicUserInfoGuard, publicUserInfoGuard,
applicationSignInExperienceGuard, applicationSignInExperienceGuard,
publicOrganizationGuard,
missingResourceScopesGuard, missingResourceScopesGuard,
type ConsentInfoResponse, type ConsentInfoResponse,
type MissingResourceScopes, type MissingResourceScopes,
@ -16,8 +15,10 @@ import { type IRouterParamContext } from 'koa-router';
import { errors } from 'oidc-provider'; import { errors } from 'oidc-provider';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { consent, getMissingScopes } from '#src/libraries/session.js'; import { consent, getMissingScopes } from '#src/libraries/session.js';
import koaGuard from '#src/middleware/koa-guard.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 Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.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<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>( export default function consentRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>, router: Router<unknown, WithInteractionDetailsContext<T>>,
{ { provider, queries, libraries }: TenantContext
provider,
queries,
libraries: {
applications: { validateUserConsentOrganizationMembership },
},
}: TenantContext
) { ) {
const {
applications: { validateUserConsentOrganizationMembership },
} = libraries;
const consentPath = `${interactionPrefix}/consent`; const consentPath = `${interactionPrefix}/consent`;
router.post( router.post(
@ -201,12 +249,42 @@ export default function consentRoutes<T extends IRouterParamContext>(
const userInfo = await queries.users.findUserById(accountId); 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 // Find the organizations if the application is requesting the organizations scope
const organizations = missingOIDCScope?.includes(UserScope.Organizations) const organizations = missingOIDCScope?.includes(UserScope.Organizations)
? await queries.organizations.relations.users.getOrganizationsByUserId(accountId) ? 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 = { ctx.body = {
// Merge the public application data and application sign-in-experience data // Merge the public application data and application sign-in-experience data
@ -218,15 +296,12 @@ export default function consentRoutes<T extends IRouterParamContext>(
), ),
}, },
user: publicUserInfoGuard.parse(userInfo), user: publicUserInfoGuard.parse(userInfo),
organizations: organizations?.map((organization) => organizations: organizationsWithMissingResourceScopes,
publicOrganizationGuard.parse(organization)
),
// Filter out the OIDC scopes that are not needed for the consent page. // Filter out the OIDC scopes that are not needed for the consent page.
missingOIDCScope: missingOIDCScope?.filter( missingOIDCScope: missingOIDCScope?.filter(
(scope) => scope !== 'openid' && scope !== 'offline_access' (scope) => scope !== 'openid' && scope !== 'offline_access'
), ),
// Parse the missing resource scopes info with details. missingResourceScopes,
missingResourceScopes: await parseMissingResourceScopesInfo(queries, missingResourceScopes),
redirectUri, redirectUri,
} satisfies ConsentInfoResponse; } satisfies ConsentInfoResponse;

View file

@ -7,9 +7,18 @@ import { assignUserConsentScopes } from '#src/api/application-user-consent-scope
import { createApplication, deleteApplication } from '#src/api/application.js'; import { createApplication, deleteApplication } from '#src/api/application.js';
import { getConsentInfo, putInteraction } from '#src/api/interaction.js'; import { getConsentInfo, putInteraction } from '#src/api/interaction.js';
import { OrganizationScopeApi } from '#src/api/organization-scope.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 { initClient } from '#src/helpers/client.js';
import { OrganizationApiTest, OrganizationRoleApiTest } from '#src/helpers/organization.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js'; import { generateNewUser } from '#src/helpers/user.js';
import {
generateResourceIndicator,
generateResourceName,
generateRoleName,
generateScopeName,
} from '#src/utils.js';
describe('consent api', () => { describe('consent api', () => {
const applications = new Map<string, Application>(); const applications = new Map<string, Application>();
@ -126,6 +135,63 @@ describe('consent api', () => {
await deleteUser(user.id); 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 () => { afterAll(async () => {
for (const application of applications.values()) { for (const application of applications.values()) {
void deleteApplication(application.id); void deleteApplication(application.id);

View file

@ -37,14 +37,6 @@ export const applicationSignInExperienceGuard = ApplicationSignInExperiences.gua
termsOfUseUrl: true, 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({ 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. // 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. // 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<typeof missingResourceScopesGuard>; export type MissingResourceScopes = z.infer<typeof missingResourceScopesGuard>;
/**
* 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<typeof publicOrganizationGuard>;
export const consentInfoResponseGuard = z.object({ export const consentInfoResponseGuard = z.object({
application: publicApplicationGuard.merge(applicationSignInExperienceGuard.partial()), application: publicApplicationGuard.merge(applicationSignInExperienceGuard.partial()),
user: publicUserInfoGuard, user: publicUserInfoGuard,