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:
parent
0227822b2d
commit
7244dadf69
10 changed files with 440 additions and 183 deletions
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
131
packages/core/src/routes/interaction/consent/utils.ts
Normal file
131
packages/core/src/routes/interaction/consent/utils.ts
Normal 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);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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>();
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue