Skip to content

Commit

Permalink
feat(core,schemas): add org resource scopes to consent get
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Apr 30, 2024
1 parent 5adf3df commit b3c1e59
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 24 deletions.
22 changes: 15 additions & 7 deletions packages/core/src/oidc/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type Resource } from '@logto/schemas';
import { trySafe, type Nullable } from '@silverhand/essentials';
import { type ResourceServer, type KoaContextWithOIDC } 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 Down Expand Up @@ -147,15 +147,23 @@ export const filterResourceScopesForTheThirdPartyApplication = async (
({ resource }) => resource.indicator === indicator
);

// If the resource is not in the application enabled user consent resources, return empty array
if (!userConsentResource) {
return [];
}
// Get the organization API resource scopes that are enabled in the application
const userConsentOrganizationResources = EnvSet.values.isDevFeaturesEnabled
? await getApplicationUserConsentResourceScopes(applicationId)
: [];
const userConsentOrganizationResource = userConsentOrganizationResources.find(
({ resource }) => resource.indicator === indicator
);

Check warning on line 156 in packages/core/src/oidc/resource.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/resource.ts#L150-L156

Added lines #L150 - L156 were not covered by tests

const { scopes: userConsentResourceScopes } = userConsentResource;
// Combine the resource scopes and organization resource scopes,
// may contain duplicated scopes
const allResourceScopes = [
...(userConsentResource?.scopes ?? []),
...(userConsentOrganizationResource?.scopes ?? []),
];

Check warning on line 163 in packages/core/src/oidc/resource.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/resource.ts#L158-L163

Added lines #L158 - L163 were not covered by tests

return scopes.filter(({ id: resourceScopeId }) =>
userConsentResourceScopes.some(
allResourceScopes.some(

Check warning on line 166 in packages/core/src/oidc/resource.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/resource.ts#L166

Added line #L166 was not covered by tests
({ id: consentResourceScopeId }) => consentResourceScopeId === resourceScopeId
)
);
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
OrganizationRoleResourceScopeRelations,
Scopes,
Resources,
type Scope,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand Down Expand Up @@ -55,6 +56,27 @@ class OrganizationRolesQueries extends SchemaQueries<
]);
}

async findResourceScopesByRoleIds(roleIds: string[]) {
if (roleIds.length === 0) {
return [];
}

const resourceScopeRelations = convertToIdentifiers(
OrganizationRoleResourceScopeRelations,
true
);
const { table, fields } = convertToIdentifiers(Scopes, true);

return this.pool.many<Scope>(sql`
select
${sql.join(Object.values(fields), sql`, `)}
from ${table}
left join ${resourceScopeRelations.table}
on ${resourceScopeRelations.fields.scopeId} = ${fields.id}
where ${resourceScopeRelations.fields.organizationRoleId} in (${sql.join(roleIds, sql`, `)})
`);
}

Check warning on line 78 in packages/core/src/queries/organization/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/organization/index.ts#L60-L78

Added lines #L60 - L78 were not covered by tests

#findWithScopesSql(
roleId?: string,
limit = 1,
Expand Down
100 changes: 91 additions & 9 deletions packages/core/src/routes/interaction/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import {
publicApplicationGuard,
publicUserInfoGuard,
applicationSignInExperienceGuard,
publicOrganizationGuard,
missingResourceScopesGuard,
type ConsentInfoResponse,
type MissingResourceScopes,
type Scope,
type OrganizationWithRoles,
type PublicOrganization,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router';
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 type Queries from '#src/tenants/Queries.js';
Expand Down Expand Up @@ -96,13 +98,47 @@ const parseMissingResourceScopesInfo = async (
);
};

/**
* Parse the missing resource scopes info with details. We need to display the resource name and scope details on the consent page.
*/
const parseMissingOrganizationResourceScopesInfo = async (
queries: Queries,
missingResourceScopes: MissingResourceScopes[],
organizations: readonly OrganizationWithRoles[]
): Promise<PublicOrganization[]> => {
return Promise.all(
organizations.map(async ({ name, id, organizationRoles }) => {
if (!EnvSet.values.isDevFeaturesEnabled) {
return { name, id };
}

const assignedScopes = await queries.organizations.roles.findResourceScopesByRoleIds(
organizationRoles.map(({ id }) => id)
);

const organizationMissingResourceScopes = missingResourceScopes.map((item) => {
return {
...item,
scopes: item.scopes.filter((scope) => assignedScopes.some(({ id }) => id === scope.id)),
};
});

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

Check warning on line 129 in packages/core/src/routes/interaction/consent.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/consent.ts#L105-L129

Added lines #L105 - L129 were not covered by tests

export default function consentRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
{
provider,
queries,
libraries: {
applications: { validateUserConsentOrganizationMembership },
applications: {
validateUserConsentOrganizationMembership,
getApplicationUserConsentResourceScopes,
getApplicationUserConsentOrganizationResourceScopes,
},
},
}: TenantContext
) {
Expand Down Expand Up @@ -201,12 +237,61 @@ 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.
const applicationUserResourceScopes = await getApplicationUserConsentResourceScopes(clientId);
const missingResourceScopes = await parseMissingResourceScopesInfo(
queries,
Object.fromEntries(
Object.entries(allMissingResourceScopes).map(([resourceIndicator, scopes]) => {
if (!EnvSet.values.isDevFeaturesEnabled) {
return [resourceIndicator, scopes];
}

const resource = applicationUserResourceScopes.find(
({ resource }) => resource.indicator === resourceIndicator
);

return [
resourceIndicator,
scopes.filter((scope) => !resource?.scopes.some(({ name }) => name === scope)),
];
})
)
);
const applicationUserOrganizationResourceScopes =
await getApplicationUserConsentOrganizationResourceScopes(clientId);
const missingOrganizationResourceScopes = await parseMissingResourceScopesInfo(
queries,
Object.fromEntries(
Object.entries(allMissingResourceScopes).map(([resourceIndicator, scopes]) => {
const resource = applicationUserOrganizationResourceScopes.find(
({ resource }) => resource.indicator === resourceIndicator
);

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

Check warning on line 282 in packages/core/src/routes/interaction/consent.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/consent.ts#L240-L282

Added lines #L240 - L282 were not covered by tests

// 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 parseMissingOrganizationResourceScopesInfo(
queries,
missingOrganizationResourceScopes,
organizations
);

Check warning on line 294 in packages/core/src/routes/interaction/consent.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/consent.ts#L287-L294

Added lines #L287 - L294 were not covered by tests

ctx.body = {
// Merge the public application data and application sign-in-experience data
Expand All @@ -218,15 +303,12 @@ export default function consentRoutes<T extends IRouterParamContext>(
),
},
user: publicUserInfoGuard.parse(userInfo),
organizations: organizations?.map((organization) =>
publicOrganizationGuard.parse(organization)
),
organizations: organizationsWithMissingResourceScopes,

Check warning on line 306 in packages/core/src/routes/interaction/consent.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/consent.ts#L306

Added line #L306 was not covered by tests
// 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,

Check warning on line 311 in packages/core/src/routes/interaction/consent.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/consent.ts#L311

Added line #L311 was not covered by tests
redirectUri,
} satisfies ConsentInfoResponse;

Expand Down
22 changes: 14 additions & 8 deletions packages/schemas/src/types/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -57,6 +49,20 @@ export const missingResourceScopesGuard = z.object({
*/
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({
application: publicApplicationGuard.merge(applicationSignInExperienceGuard.partial()),
user: publicUserInfoGuard,
Expand Down

0 comments on commit b3c1e59

Please sign in to comment.