From 08f8688cf2bb0f99a504d0dbbba94b26b483d33f Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 19 Jan 2024 10:52:55 +0800 Subject: [PATCH] feat(core): add get consent info api (#5259) add get consent info api --- packages/core/src/libraries/session.ts | 18 ++- .../core/src/routes/interaction/consent.ts | 144 +++++++++++++++++- packages/schemas/src/types/consent.ts | 65 ++++++++ packages/schemas/src/types/index.ts | 1 + 4 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 packages/schemas/src/types/consent.ts diff --git a/packages/core/src/libraries/session.ts b/packages/core/src/libraries/session.ts index dc37a6e29..2b2ba1716 100644 --- a/packages/core/src/libraries/session.ts +++ b/packages/core/src/libraries/session.ts @@ -58,6 +58,16 @@ const saveUserFirstConsentedAppId = async ( } }; +// Get the missing scopes from prompt details +const missingScopesGuard = z.object({ + missingOIDCScope: z.string().array().optional(), + missingResourceScopes: z.object({}).catchall(z.string().array()).optional(), +}); + +export const getMissingScopes = (prompt: Provider.PromptDetail) => { + return missingScopesGuard.parse(prompt.details); +}; + export const consent = async ( ctx: Context, provider: Provider, @@ -81,13 +91,9 @@ export const consent = async ( await saveUserFirstConsentedAppId(queries, accountId, String(client_id)); - // Fulfill missing claims / resources - const PromptDetailsBody = z.object({ - missingOIDCScope: z.string().array().optional(), - missingResourceScopes: z.object({}).catchall(z.string().array()).optional(), - }); - const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details); + const { missingOIDCScope, missingResourceScopes } = getMissingScopes(prompt); + // Fulfill missing scopes if (missingOIDCScope) { grant.addOIDCScope(missingOIDCScope.join(' ')); } diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index a053a227e..056895729 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -1,17 +1,84 @@ +import { UserScope } from '@logto/core-kit'; +import { + consentInfoResponseGuard, + publicApplicationGuard, + publicUserInfoGuard, + applicationSignInExperienceGuard, + publicOrganizationGuard, + missingResourceScopesGuard, + type ConsentInfoResponse, + type MissingResourceScopes, + type Scope, +} 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 { consent } from '#src/libraries/session.js'; +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'; import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix } from './const.js'; import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; +const { InvalidClient, InvalidTarget } = errors; + +/** + * Parse the missing resource scopes info with details. We need to display the resource name and scope details on the consent page. + */ +const parseMissingResourceScopesInfo = async ( + queries: Queries, + missingResourceScopes?: Record +): Promise => { + if (!missingResourceScopes) { + return []; + } + + const resourcesWithScopes = await Promise.all( + Object.entries(missingResourceScopes).map(async ([resourceIndicator, scopeNames]) => { + const resource = await queries.resources.findResourceByIndicator(resourceIndicator); + + // Will be guarded by OIDC provider, should not happen + assertThat( + resource, + new InvalidTarget(`resource with indicator ${resourceIndicator} not found`) + ); + + // Find the scopes details + const scopes = await Promise.all( + scopeNames.map(async (scopeName) => + queries.scopes.findScopeByNameAndResourceId(scopeName, resource.id) + ) + ); + + return { + resource, + scopes: scopes + // eslint-disable-next-line no-implicit-coercion -- filter out not found scopes (should not happen) + .filter((scope): scope is Scope => !!scope), + }; + }) + ); + + return ( + resourcesWithScopes + // Filter out if all resource scopes are not found (should not happen) + .filter(({ scopes }) => scopes.length > 0) + .map((resourceWithGroups) => missingResourceScopesGuard.parse(resourceWithGroups)) + ); +}; + export default function consentRoutes( router: Router>, { provider, queries }: TenantContext ) { - router.post(`${interactionPrefix}/consent`, async (ctx, next) => { + const consentPath = `${interactionPrefix}/consent`; + + router.post(consentPath, async (ctx, next) => { const { interactionDetails } = ctx; const redirectTo = await consent(ctx, provider, queries, interactionDetails); @@ -20,4 +87,77 @@ export default function consentRoutes( return next(); }); + + // FIXME: @simeng-li remove this when the IdP is ready + if (!EnvSet.values.isDevFeaturesEnabled) { + return; + } + + /** + * Get the consent info for the experience consent page. + */ + router.get( + consentPath, + koaGuard({ + status: [200], + response: consentInfoResponseGuard, + }), + async (ctx, next) => { + const { interactionDetails } = ctx; + + const { + session, + params: { client_id: clientId }, + prompt, + } = interactionDetails; + + assertThat(session, 'session.not_found'); + + assertThat( + clientId && typeof clientId === 'string', + new InvalidClient('client must be available') + ); + + const { accountId } = session; + + const application = await queries.applications.findApplicationById(clientId); + + const applicationSignInExperience = + await queries.applicationSignInExperiences.safeFindSignInExperienceByApplicationId( + clientId + ); + + const userInfo = await queries.users.findUserById(accountId); + + const { missingOIDCScope, missingResourceScopes } = getMissingScopes(prompt); + + // 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; + + ctx.body = { + // Merge the public application data and application sign-in-experience data + application: { + ...publicApplicationGuard.parse(application), + ...conditional( + applicationSignInExperience && + applicationSignInExperienceGuard.parse(applicationSignInExperience) + ), + }, + user: publicUserInfoGuard.parse(userInfo), + organizations: organizations?.map((organization) => + publicOrganizationGuard.parse(organization) + ), + // 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), + } satisfies ConsentInfoResponse; + + return next(); + } + ); } diff --git a/packages/schemas/src/types/consent.ts b/packages/schemas/src/types/consent.ts new file mode 100644 index 000000000..542299df0 --- /dev/null +++ b/packages/schemas/src/types/consent.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +import { + Applications, + Users, + Organizations, + Resources, + Scopes, + ApplicationSignInExperiences, +} from '../db-entries/index.js'; + +/** + * Define the public user info that can be exposed to the public. e.g. on the user consent page. + */ +export const publicUserInfoGuard = Users.guard.pick({ + id: true, + name: true, + avatar: true, + username: true, + primaryEmail: true, + primaryPhone: true, +}); +export type PublicUserInfo = z.infer; + +/** + * Define the public application info that can be exposed to the public. e.g. on the user consent page. + */ +export const publicApplicationGuard = Applications.guard.pick({ + id: true, + name: true, +}); +export type PublicApplication = z.infer; +export const applicationSignInExperienceGuard = ApplicationSignInExperiences.guard.pick({ + branding: true, + displayName: true, + privacyPolicyUrl: 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({ + resource: Resources.guard.pick({ id: true, name: true }), + scopes: Scopes.guard.pick({ id: true, name: true, description: true }).array(), +}); + +/** + * Define the missing resource scopes for the consent page. + */ +export type MissingResourceScopes = z.infer; + +export const consentInfoResponseGuard = z.object({ + application: publicApplicationGuard.merge(applicationSignInExperienceGuard.partial()), + user: publicUserInfoGuard, + organizations: publicOrganizationGuard.array().optional(), + missingOIDCScope: z.string().array().optional(), + missingResourceScopes: missingResourceScopesGuard.array().optional(), +}); + +export type ConsentInfoResponse = z.infer; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 6cddcf3e7..cd460391c 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -25,3 +25,4 @@ export * from './sso-connector.js'; export * from './tenant.js'; export * from './tenant-organization.js'; export * from './mapi-proxy.js'; +export * from './consent.js';