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

feat(core,schemas): update consent info (#5822)

This commit is contained in:
wangsijie 2024-05-09 13:32:31 +08:00 committed by GitHub
parent 0227822b2d
commit 7244dadf69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 440 additions and 183 deletions

View file

@ -59,7 +59,7 @@ describe('consent', () => {
it('should update with new grantId if not exist', async () => {
const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant);
await consent(context, provider, queries, baseInteractionDetails);
await consent({ ctx: context, provider, queries, interactionDetails: baseInteractionDetails });
expect(grantSave).toHaveBeenCalled();
@ -85,7 +85,7 @@ describe('consent', () => {
const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant);
await consent(context, provider, queries, interactionDetails);
await consent({ ctx: context, provider, queries, interactionDetails });
expect(grantSave).toHaveBeenCalled();
@ -109,7 +109,7 @@ describe('consent', () => {
}));
const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant);
await consent(context, provider, queries, baseInteractionDetails);
await consent({ ctx: context, provider, queries, interactionDetails: baseInteractionDetails });
expect(userQueries.updateUserById).toHaveBeenCalledWith(mockUser.id, {
applicationId: baseInteractionDetails.params.client_id,
@ -117,21 +117,18 @@ describe('consent', () => {
});
it('should grant missing scopes', async () => {
const interactionDetails = {
...baseInteractionDetails,
prompt: {
details: {
missingOIDCScope: ['openid', 'profile'],
missingResourceScopes: {
resource1: ['resource1_scope1', 'resource1_scope2'],
resource2: ['resource2_scope1'],
},
},
const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant);
await consent({
ctx: context,
provider,
queries,
interactionDetails: baseInteractionDetails,
missingOIDCScopes: ['openid', 'profile'],
resourceScopesToGrant: {
resource1: ['resource1_scope1', 'resource1_scope2'],
resource2: ['resource2_scope1'],
},
} as unknown as Interaction;
const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant);
await consent(context, provider, queries, interactionDetails);
});
expect(grantAddOIDCScope).toHaveBeenCalledWith('openid profile');
expect(grantAddResourceScope).toHaveBeenCalledWith(

View file

@ -68,17 +68,27 @@ export const getMissingScopes = (prompt: PromptDetail) => {
return missingScopesGuard.parse(prompt.details);
};
export const consent = async (
ctx: Context,
provider: Provider,
queries: Queries,
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>
) => {
export const consent = async ({
ctx,
provider,
queries,
interactionDetails,
missingOIDCScopes = [],
resourceScopesToGrant = {},
resourceScopesToReject = {},
}: {
ctx: Context;
provider: Provider;
queries: Queries;
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>;
missingOIDCScopes?: string[];
resourceScopesToGrant?: Record<string, string[]>;
resourceScopesToReject?: Record<string, string[]>;
}) => {
const {
session,
grantId,
params: { client_id },
prompt,
} = interactionDetails;
assertThat(session, 'session.not_found');
@ -91,17 +101,17 @@ export const consent = async (
await saveUserFirstConsentedAppId(queries, accountId, String(client_id));
const { missingOIDCScope, missingResourceScopes } = getMissingScopes(prompt);
// Fulfill missing scopes
if (missingOIDCScope) {
grant.addOIDCScope(missingOIDCScope.join(' '));
if (missingOIDCScopes.length > 0) {
grant.addOIDCScope(missingOIDCScopes.join(' '));
}
if (missingResourceScopes) {
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
grant.addResourceScope(indicator, scope.join(' '));
}
for (const [indicator, scope] of Object.entries(resourceScopesToGrant)) {
grant.addResourceScope(indicator, scope.join(' '));
}
for (const [indicator, scope] of Object.entries(resourceScopesToReject)) {
grant.rejectResourceScope(indicator, scope.join(' '));
}
const finalGrantId = await grant.save();

View file

@ -4,7 +4,7 @@ import { type IRouterParamContext } from 'koa-router';
import type Provider from 'oidc-provider';
import { errors } from 'oidc-provider';
import { consent } from '#src/libraries/session.js';
import { consent, getMissingScopes } from '#src/libraries/session.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
@ -18,7 +18,10 @@ export default function koaAutoConsent<StateT, ContextT extends IRouterParamCont
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const { client_id: clientId } = interactionDetails.params;
const {
params: { client_id: clientId },
prompt,
} = interactionDetails;
const {
applications: { findApplicationById },
@ -36,7 +39,17 @@ export default function koaAutoConsent<StateT, ContextT extends IRouterParamCont
const shouldAutoConsent = !application?.isThirdParty;
if (shouldAutoConsent) {
const redirectTo = await consent(ctx, provider, query, interactionDetails);
const { missingOIDCScope: missingOIDCScopes, missingResourceScopes: resourceScopesToGrant } =
getMissingScopes(prompt);
const redirectTo = await consent({
ctx,
provider,
queries: query,
interactionDetails,
missingOIDCScopes,
resourceScopesToGrant,
});
ctx.redirect(redirectTo);
return;

View file

@ -1,15 +1,13 @@
import { ReservedResource, UserScope } from '@logto/core-kit';
import { UserScope } from '@logto/core-kit';
import {
consentInfoResponseGuard,
publicApplicationGuard,
publicUserInfoGuard,
applicationSignInExperienceGuard,
missingResourceScopesGuard,
type ConsentInfoResponse,
type MissingResourceScopes,
type Scope,
Organizations,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { conditional, deduplicate } from '@silverhand/essentials';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { errors } from 'oidc-provider';
@ -18,134 +16,15 @@ 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 { findResourceScopes } from '#src/oidc/resource.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';
import { interactionPrefix } from '../const.js';
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
const { InvalidClient, InvalidTarget, InvalidRedirectUri } = errors;
import { filterAndParseMissingResourceScopes } from './utils.js';
/**
* 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]) => {
// Organization resources are reserved resources, we don't need to find the resource details
if (resourceIndicator === ReservedResource.Organization) {
const [_, organizationScopes] = await queries.organizations.scopes.findAll();
const scopes = scopeNames.map((scopeName) => {
const scope = organizationScopes.find((scope) => scope.name === scopeName);
// Will be guarded by OIDC provider, should not happen
assertThat(
scope,
new InvalidTarget(`scope with name ${scopeName} not found for organization resource`)
);
return scope;
});
return {
resource: {
id: resourceIndicator,
name: resourceIndicator,
},
scopes,
};
}
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))
);
};
/**
* 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);
};
const { InvalidClient, InvalidRedirectUri } = errors;
export default function consentRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
@ -172,22 +51,23 @@ export default function consentRoutes<T extends IRouterParamContext>(
},
} = ctx;
const {
session,
params: { client_id: applicationId },
prompt,
} = interactionDetails;
assertThat(session, 'session.not_found');
assertThat(
applicationId && typeof applicationId === 'string',
new InvalidClient('client must be available')
);
const { accountId: userId } = session;
// Grant the organizations to the application if the user has selected the organizations
if (organizationIds?.length) {
const {
session,
params: { client_id: applicationId },
} = interactionDetails;
assertThat(session, 'session.not_found');
assertThat(
applicationId && typeof applicationId === 'string',
new InvalidClient('client must be available')
);
const { accountId: userId } = session;
// Assert that user is a member of all organizations
await validateUserConsentOrganizationMembership(userId, organizationIds);
@ -200,7 +80,115 @@ export default function consentRoutes<T extends IRouterParamContext>(
);
}
const redirectTo = await consent(ctx, provider, queries, interactionDetails);
const { missingOIDCScope = [], missingResourceScopes: allMissingResourceScopes = {} } =
getMissingScopes(prompt);
/* === Rebuild resource scopes === */
// The resource scopes saved in the prompt details lost the organization information.
// Instead of trust the front-end's submission, we choose to find the organizations and build the resource scopes again,
// to ensure the scopes are correct.
// Find the organizations granted by the user
// The user may send consent request multiple times, so we need to find the organizations again
const [, organizations] = EnvSet.values.isDevFeaturesEnabled
? await queries.applications.userConsentOrganizations.getEntities(Organizations, {
applicationId,
userId,
})
: [0, []];
// 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,
});
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,
organizationId: id,
});
return { name, id, missingResourceScopes };
})
);
/* === End rebuild resource scopes === */
// Join the missing resource scopes from the prompt details and the missing resource scopes from the organizations
const resourceScopesEntries: Array<[string, string[]]> = missingResourceScopes.map(
({ resource, scopes }) => [resource.indicator, scopes.map(({ name }) => name)]
);
const resourceScopesToGrant: Record<string, string[]> = Object.fromEntries(
organizationsWithMissingResourceScopes.reduce<Array<[string, string[]]>>(
(entries, { missingResourceScopes }) => {
if (!missingResourceScopes) {
return entries;
}
const organizationEntries: Array<[string, string[]]> = missingResourceScopes.map(
({ resource, scopes }) => [resource.indicator, scopes.map(({ name }) => name)]
);
// The entries whoes resource indecator is not in the prev entries
const newEntries: Array<[string, string[]]> = organizationEntries.filter(
([resourceIndicator]) =>
!entries.some(([indicator]) => indicator === resourceIndicator)
);
const existingEntries: Array<[string, string[]]> = entries.map(
([indicator, scopes]) => {
const organizationEntry = organizationEntries.find(
([resourceIndicator]) => resourceIndicator === indicator
);
if (!organizationEntry) {
return [indicator, scopes];
}
return [indicator, deduplicate([...scopes, ...organizationEntry[1]])];
}
);
return [...newEntries, ...existingEntries];
},
resourceScopesEntries
)
);
const resourceScopesToReject = Object.fromEntries(
Object.entries(allMissingResourceScopes).map(([resourceIndicator, scopes]) => {
const resource = resourceScopesToGrant[resourceIndicator];
if (!resource) {
return [resourceIndicator, []];
}
return [resourceIndicator, scopes.filter((scope) => !resource.includes(scope))];
})
);
const redirectTo = await consent({
ctx,
provider,
queries,
interactionDetails,
missingOIDCScopes: missingOIDCScope,
resourceScopesToGrant,
resourceScopesToReject,
});
ctx.body = { redirectTo };

View file

@ -0,0 +1,131 @@
import { ReservedResource } from '@logto/core-kit';
import { type MissingResourceScopes, type Scope, missingResourceScopesGuard } from '@logto/schemas';
import { errors } from 'oidc-provider';
import { EnvSet } from '#src/env-set/index.js';
import { findResourceScopes } from '#src/oidc/resource.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
const { 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]) => {
// Organization resources are reserved resources, we don't need to find the resource details
if (resourceIndicator === ReservedResource.Organization) {
const [_, organizationScopes] = await queries.organizations.scopes.findAll();
const scopes = scopeNames.map((scopeName) => {
const scope = organizationScopes.find((scope) => scope.name === scopeName);
// Will be guarded by OIDC provider, should not happen
assertThat(
scope,
new InvalidTarget(`scope with name ${scopeName} not found for organization resource`)
);
return scope;
});
return {
resource: {
id: resourceIndicator,
name: resourceIndicator,
indicator: resourceIndicator,
},
scopes,
};
}
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))
);
};
/**
* 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.
*/
export const filterAndParseMissingResourceScopes = async ({
resourceScopes,
queries,
libraries,
userId,
organizationId,
}: {
resourceScopes: Record<string, string[]>;
queries: Queries;
libraries: 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);
};

View file

@ -13,7 +13,7 @@ import type { AnonymousRouter, RouterInitArgs } from '../types.js';
import submitInteraction from './actions/submit-interaction.js';
import additionalRoutes from './additional.js';
import consentRoutes from './consent.js';
import consentRoutes from './consent/index.js';
import { interactionPrefix } from './const.js';
import mfaRoutes from './mfa.js';
import koaInteractionDetails from './middleware/koa-interaction-details.js';

View file

@ -133,7 +133,7 @@ export const skipMfaBinding = async (cookie: string) =>
},
});
export const consent = async (api: KyInstance, cookie: string) =>
export const consent = async (cookie: string, payload: { organizationIds?: string[] } = {}) =>
api
.post('interaction/consent', {
headers: {
@ -141,6 +141,7 @@ export const consent = async (api: KyInstance, cookie: string) =>
},
redirect: 'manual',
throwHttpErrors: false,
json: payload,
})
.json<RedirectResponse>();

View file

@ -127,6 +127,23 @@ export default class MockClient {
await this.logto.handleSignInCallback(signInCallbackUri);
}
public async manualConsent(redirectTo: string) {
const authCodeResponse = await ky.get(redirectTo, {
headers: {
cookie: this.interactionCookie,
},
redirect: 'manual',
throwHttpErrors: false,
});
// Note: Should redirect to the signInCallbackUri
assert(authCodeResponse.status === 303, new Error('Complete auth failed'));
const signInCallbackUri = authCodeResponse.headers.get('location');
assert(signInCallbackUri, new Error('Get sign in callback uri failed'));
return this.logto.handleSignInCallback(signInCallbackUri);
}
public async getAccessToken(resource?: string, organizationId?: string) {
return this.logto.getAccessToken(resource, organizationId);
}

View file

@ -5,7 +5,7 @@ import { assert } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { assignUserConsentScopes } from '#src/api/application-user-consent-scope.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { getConsentInfo, putInteraction } from '#src/api/interaction.js';
import { consent, getConsentInfo, putInteraction } from '#src/api/interaction.js';
import { OrganizationScopeApi } from '#src/api/organization-scope.js';
import { createResource, deleteResource } from '#src/api/resource.js';
import { createScope } from '#src/api/scope.js';
@ -18,6 +18,7 @@ import {
generateResourceName,
generateRoleName,
generateScopeName,
getAccessTokenPayload,
} from '#src/utils.js';
describe('consent api', () => {
@ -92,7 +93,7 @@ describe('consent api', () => {
const organizationScopeApi = new OrganizationScopeApi();
const organizationScope = await organizationScopeApi.create({
name: 'organization-scope',
name: generateScopeName(),
});
await assignUserConsentScopes(application.id, {
@ -192,6 +193,105 @@ describe('consent api', () => {
await deleteUser(user.id);
});
describe('submit consent info', () => {
it('should perform manual consent successfully', async () => {
const application = applications.get(thirdPartyApplicationName);
assert(application, new Error('application.not_found'));
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const client = await initClient(
{
appId: application.id,
appSecret: application.secret,
},
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 { redirectTo: consentRedirectTo } = await client.send(consent);
await client.manualConsent(consentRedirectTo);
await deleteUser(user.id);
});
it('consent with organization id and verify access token scope', 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 role2 = await roleApi.create({
name: generateRoleName(),
resourceScopeIds: [scope2.id],
});
const organizationApi = new OrganizationApiTest();
const organization = await organizationApi.create({ name: 'test_org_1' });
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]);
const organization2 = await organizationApi.create({ name: 'test_org_2' });
await organizationApi.addUsers(organization2.id, [user.id]);
await organizationApi.addUserRoles(organization2.id, user.id, [role2.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 { redirectTo: consentRedirectTo } = await client.send(consent, {
organizationIds: [organization.id],
});
await client.manualConsent(consentRedirectTo);
const accessToken = await client.getAccessToken(resource.indicator, organization.id);
// Scope2 is removed because organization2 is not consented
expect(getAccessTokenPayload(accessToken)).toHaveProperty('scope', scope.name);
await roleApi.cleanUp();
await organizationApi.cleanUp();
await deleteResource(resource.id);
await deleteUser(user.id);
});
});
afterAll(async () => {
for (const application of applications.values()) {
void deleteApplication(application.id);

View file

@ -40,7 +40,7 @@ export const applicationSignInExperienceGuard = ApplicationSignInExperiences.gua
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.
resource: Resources.guard.pick({ name: true }).extend({ id: z.string() }),
resource: Resources.guard.pick({ name: true, indicator: true }).extend({ id: z.string() }),
scopes: Scopes.guard.pick({ id: true, name: true, description: true }).array(),
});