0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core): add get consent info api (#5259)

add get consent info api
This commit is contained in:
simeng-li 2024-01-19 10:52:55 +08:00 committed by GitHub
parent 94908ee8ce
commit 08f8688cf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 220 additions and 8 deletions

View file

@ -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(' '));
}

View file

@ -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<string, string[]>
): Promise<MissingResourceScopes[]> => {
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<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
{ 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<T extends IRouterParamContext>(
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();
}
);
}

View file

@ -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<typeof publicUserInfoGuard>;
/**
* 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<typeof publicApplicationGuard>;
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<typeof missingResourceScopesGuard>;
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<typeof consentInfoResponseGuard>;

View file

@ -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';