mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
parent
94908ee8ce
commit
08f8688cf2
4 changed files with 220 additions and 8 deletions
|
@ -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(' '));
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
65
packages/schemas/src/types/consent.ts
Normal file
65
packages/schemas/src/types/consent.ts
Normal 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>;
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Reference in a new issue