mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): issue organization token via client credentials (#6098)
* feat(core): issue organization token via client credentials * refactor: fix tests
This commit is contained in:
parent
75c0468abe
commit
b590e64f59
11 changed files with 624 additions and 31 deletions
157
packages/core/src/oidc/grants/client-credentials.test.ts
Normal file
157
packages/core/src/oidc/grants/client-credentials.test.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { isKeyInObject } from '@silverhand/essentials';
|
||||
import { type KoaContextWithOIDC, errors, type Adapter } from 'oidc-provider';
|
||||
|
||||
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
jest.unstable_mockModule('oidc-provider/lib/shared/check_resource.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('oidc-provider/lib/helpers/weak_cache.js', () => ({
|
||||
default: jest.fn().mockReturnValue({
|
||||
configuration: jest.fn().mockReturnValue({
|
||||
features: {
|
||||
mTLS: { getCertificate: jest.fn() },
|
||||
},
|
||||
scopes: new Set(['foo', 'bar']),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = async () => {};
|
||||
|
||||
const clientId = 'some_client_id';
|
||||
const requestScopes = ['foo', 'bar'];
|
||||
|
||||
const mockAdapter: Adapter = {
|
||||
upsert: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findByUserCode: jest.fn(),
|
||||
findByUid: jest.fn(),
|
||||
consume: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
revokeByGrantId: jest.fn(),
|
||||
};
|
||||
|
||||
type ClientCredentials = InstanceType<KoaContextWithOIDC['oidc']['provider']['ClientCredentials']>;
|
||||
type Client = InstanceType<KoaContextWithOIDC['oidc']['provider']['Client']>;
|
||||
|
||||
const validClientCredentials: ClientCredentials = {
|
||||
kind: 'ClientCredentials',
|
||||
clientId,
|
||||
aud: '',
|
||||
tokenType: '',
|
||||
isSenderConstrained: jest.fn().mockReturnValue(false),
|
||||
iat: 0,
|
||||
jti: '',
|
||||
scope: requestScopes.join(' '),
|
||||
scopes: new Set(requestScopes),
|
||||
ttlPercentagePassed: jest.fn(),
|
||||
isValid: false,
|
||||
isExpired: false,
|
||||
remainingTTL: 0,
|
||||
expiration: 0,
|
||||
save: jest.fn(),
|
||||
adapter: mockAdapter,
|
||||
destroy: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
const createValidClient = ({ scope }: { scope?: string } = {}): Client => ({
|
||||
clientId,
|
||||
grantTypeAllowed: jest.fn().mockResolvedValue(true),
|
||||
clientAuthMethod: 'none',
|
||||
scope,
|
||||
});
|
||||
|
||||
const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
|
||||
params: {
|
||||
refresh_token: 'some_refresh_token',
|
||||
organization_id: 'some_org_id',
|
||||
scope: requestScopes.join(' '),
|
||||
},
|
||||
client: createValidClient(),
|
||||
};
|
||||
|
||||
const { buildHandler } = await import('./client-credentials.js');
|
||||
|
||||
const mockHandler = (tenant = new MockTenant()) => {
|
||||
return buildHandler(tenant.envSet, tenant.queries);
|
||||
};
|
||||
|
||||
// The handler returns void so we cannot check the return value, and it's also not
|
||||
// straightforward to assert the token is issued correctly. Here we just do the sanity
|
||||
// check and basic token validation. Comprehensive token validation should be done in
|
||||
// integration tests.
|
||||
describe('client credentials grant', () => {
|
||||
it('should throw an error if the client is not available', async () => {
|
||||
const ctx = createOidcContext({ ...validOidcContext, client: undefined });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient);
|
||||
});
|
||||
|
||||
it('should throw an error if the requested scope is not allowed', async () => {
|
||||
const ctx = createOidcContext({
|
||||
...validOidcContext,
|
||||
client: createValidClient({ scope: 'baz' }),
|
||||
});
|
||||
await expect(
|
||||
mockHandler(
|
||||
new MockTenant(undefined, {
|
||||
organizations: {
|
||||
relations: {
|
||||
// @ts-expect-error
|
||||
apps: {
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)(ctx, noop)
|
||||
).rejects.toThrow(errors.InvalidScope);
|
||||
});
|
||||
|
||||
it('should throw an error if the app has not associated with the organization', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
await expect(
|
||||
mockHandler(
|
||||
new MockTenant(undefined, {
|
||||
organizations: {
|
||||
relations: {
|
||||
// @ts-expect-error
|
||||
apps: {
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)(ctx, noop)
|
||||
).rejects.toThrow(errors.AccessDenied);
|
||||
});
|
||||
|
||||
it('should be ok', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
await expect(
|
||||
mockHandler(
|
||||
new MockTenant(undefined, {
|
||||
organizations: {
|
||||
relations: {
|
||||
// @ts-expect-error
|
||||
apps: {
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
// @ts-expect-error
|
||||
appsRoles: { getApplicationScopes: jest.fn().mockResolvedValue([{ name: 'foo' }]) },
|
||||
},
|
||||
},
|
||||
})
|
||||
)(ctx, noop)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(isKeyInObject(ctx.body, 'scope') && ctx.body.scope).toBe('foo');
|
||||
});
|
||||
});
|
|
@ -20,6 +20,8 @@
|
|||
* The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`.
|
||||
*/
|
||||
|
||||
import { buildOrganizationUrn } from '@logto/core-kit';
|
||||
import { cond } from '@silverhand/essentials';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
import epochTime from 'oidc-provider/lib/helpers/epoch_time.js';
|
||||
|
@ -27,17 +29,19 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js';
|
|||
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||
import checkResource from 'oidc-provider/lib/shared/check_resource.js';
|
||||
|
||||
import { type EnvSet } from '#src/env-set/index.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
const { InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
||||
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
|
||||
|
||||
const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
||||
|
||||
/**
|
||||
* The valid parameters for the `client_credentials` grant type. Note the `resource` parameter is
|
||||
* not included here since it should be handled per configuration when registering the grant type.
|
||||
*/
|
||||
export const parameters = Object.freeze(['scope']);
|
||||
export const parameters = Object.freeze(['scope', 'organization_id']);
|
||||
|
||||
// We have to disable the rules because the original implementation is written in JavaScript and
|
||||
// uses mutable variables.
|
||||
|
@ -45,9 +49,9 @@ export const parameters = Object.freeze(['scope']);
|
|||
export const buildHandler: (
|
||||
envSet: EnvSet,
|
||||
queries: Queries
|
||||
// eslint-disable-next-line complexity, unicorn/consistent-function-scoping
|
||||
) => Parameters<Provider['registerGrantType']>[1] = (_envSet, _queries) => async (ctx, next) => {
|
||||
const { client } = ctx.oidc;
|
||||
// eslint-disable-next-line complexity
|
||||
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
|
||||
const { client, params } = ctx.oidc;
|
||||
const { ClientCredentials, ReplayDetection } = ctx.oidc.provider;
|
||||
|
||||
assertThat(client, new InvalidClient('client must be available'));
|
||||
|
@ -61,8 +65,40 @@ export const buildHandler: (
|
|||
|
||||
const dPoP = await dpopValidate(ctx);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
await checkResource(ctx, async () => {});
|
||||
/* === RFC 0001 === */
|
||||
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
||||
// to `Boolean` first.
|
||||
const organizationId = cond(Boolean(params?.organization_id) && String(params?.organization_id));
|
||||
// TODO: Remove
|
||||
if (!EnvSet.values.isDevFeaturesEnabled && organizationId) {
|
||||
throw new InvalidTarget('organization tokens are not supported yet');
|
||||
}
|
||||
|
||||
if (
|
||||
organizationId &&
|
||||
!(await queries.organizations.relations.apps.exists({
|
||||
organizationId,
|
||||
applicationId: client.clientId,
|
||||
}))
|
||||
) {
|
||||
const error = new AccessDenied('app has not associated with the organization');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
// Do not check the resource if the organization ID is provided and the resource is not. In this
|
||||
// case, the default resource server will be ignored, and an organization token will be issued.
|
||||
if (!(organizationId && !params?.resource)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
await checkResource(ctx, async () => {});
|
||||
}
|
||||
|
||||
const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {});
|
||||
|
||||
if (!organizationId && length === 0) {
|
||||
throw new InvalidTarget('both `resource` and `organization_id` are not provided');
|
||||
}
|
||||
|
||||
const scopes = ctx.oidc.params?.scope
|
||||
? [...new Set(String(ctx.oidc.params.scope).split(' '))]
|
||||
|
@ -83,7 +119,6 @@ export const buildHandler: (
|
|||
scope: scopes.join(' ') || undefined!,
|
||||
});
|
||||
|
||||
const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {});
|
||||
if (resourceServer) {
|
||||
if (length !== 1) {
|
||||
throw new InvalidTarget(
|
||||
|
@ -96,6 +131,33 @@ export const buildHandler: (
|
|||
undefined;
|
||||
}
|
||||
|
||||
// Issue organization token only if resource server is not present.
|
||||
// If it's present, the flow falls into the `checkResource` and `if (resourceServer)` block above.
|
||||
if (organizationId && !resourceServer) {
|
||||
/* === RFC 0001 === */
|
||||
const audience = buildOrganizationUrn(organizationId);
|
||||
const availableScopes = await queries.organizations.relations.appsRoles
|
||||
.getApplicationScopes(organizationId, client.clientId)
|
||||
.then((scope) => scope.map(({ name }) => name));
|
||||
|
||||
/** The intersection of the available scopes and the requested scopes. */
|
||||
const issuedScopes = availableScopes.filter((scope) => scopes.includes(scope)).join(' ');
|
||||
|
||||
token.aud = audience;
|
||||
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
|
||||
// server. But it's not available in the typings. The class is actually very simple and holds
|
||||
// no provider-specific context. So we just create the object manually.
|
||||
// See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js
|
||||
token.resourceServer = {
|
||||
...getSharedResourceServerData(envSet),
|
||||
accessTokenTTL: reversedResourceAccessTokenTtl,
|
||||
audience,
|
||||
scope: availableScopes.join(' '),
|
||||
};
|
||||
token.scope = issuedScopes;
|
||||
/* === End RFC 0001 === */
|
||||
}
|
||||
|
||||
if (client.tlsClientCertificateBoundAccessTokens) {
|
||||
const cert = getCertificate(ctx);
|
||||
|
||||
|
|
|
@ -154,7 +154,11 @@ afterAll(() => {
|
|||
Sinon.restore();
|
||||
});
|
||||
|
||||
describe('organization token grant', () => {
|
||||
// The handler returns void so we cannot check the return value, and it's also not
|
||||
// straightforward to assert the token is issued correctly. Here we just do the sanity
|
||||
// check and basic token validation. Comprehensive token validation should be done in
|
||||
// integration tests.
|
||||
describe('refresh token grant', () => {
|
||||
it('should throw when client is not available', async () => {
|
||||
const ctx = createOidcContext({ ...validOidcContext, client: undefined });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient);
|
||||
|
@ -307,10 +311,6 @@ describe('organization token grant', () => {
|
|||
);
|
||||
});
|
||||
|
||||
// The handler returns void so we cannot check the return value, and it's also not
|
||||
// straightforward to assert the token is issued correctly. Here we just do the sanity
|
||||
// check and basic token validation. Comprehensive token validation should be done in
|
||||
// integration tests.
|
||||
it('should not explode when everything looks fine', async () => {
|
||||
const ctx = createPreparedContext();
|
||||
const tenant = new MockTenant();
|
||||
|
|
|
@ -216,7 +216,6 @@ export const buildHandler: (
|
|||
}
|
||||
|
||||
/* === RFC 0001 === */
|
||||
|
||||
if (organizationId) {
|
||||
// Check membership
|
||||
if (
|
||||
|
@ -325,7 +324,7 @@ export const buildHandler: (
|
|||
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
|
||||
|
||||
// Note, issue organization token only if `params.resource` is not present.
|
||||
// If resource is set, will issue normal access token with extra claim "organization_id",
|
||||
// If resource is set, we will issue normal access token with extra claim "organization_id",
|
||||
// the logic is handled in `getResourceServerInfo` and `extraTokenClaims`, see the init file of oidc-provider.
|
||||
if (organizationId && !params.resource) {
|
||||
/* === RFC 0001 === */
|
||||
|
|
|
@ -131,7 +131,9 @@ export default function initOidc(
|
|||
enabled: true,
|
||||
defaultResource: async () => {
|
||||
const resource = await findDefaultResource();
|
||||
return resource?.indicator ?? '';
|
||||
// The default implementation returns `undefined` - https://github.com/panva/node-oidc-provider/blob/0c52469f08b0a4a1854d90a96546a3f7aa090e5e/lib/helpers/defaults.js#L195
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return resource?.indicator ?? undefined!;
|
||||
},
|
||||
// Disable the auto use of authorization_code granted resource feature
|
||||
useGrantedResource: () => false,
|
||||
|
@ -147,13 +149,6 @@ export default function initOidc(
|
|||
const { client, params, session, entities } = ctx.oidc;
|
||||
const userId = session?.accountId ?? entities.Account?.accountId;
|
||||
|
||||
/**
|
||||
* In consent or code exchange flow, the organization_id is undefined,
|
||||
* and all the scopes inherited from the all organization roles will be granted.
|
||||
* In the flow of granting token for organization with api resource,
|
||||
* this value is set to the organization id,
|
||||
* and will then narrow down the scopes to the specific organization.
|
||||
*/
|
||||
const organizationId = params?.organization_id;
|
||||
const scopes = await findResourceScopes({
|
||||
queries,
|
||||
|
@ -228,7 +223,6 @@ export default function initOidc(
|
|||
},
|
||||
},
|
||||
extraParams: Object.values(ExtraParamsKey),
|
||||
|
||||
extraTokenClaims: async (ctx, token) => {
|
||||
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
|
||||
ctx,
|
||||
|
|
|
@ -20,9 +20,12 @@ export const getSharedResourceServerData = (
|
|||
},
|
||||
});
|
||||
|
||||
// TODO: Refactor me. This function is too complex.
|
||||
/**
|
||||
* Find the scopes for a given resource indicator according to the subject in the
|
||||
* context. The subject can be either a user or an application.
|
||||
* Find the scopes for a given resource indicator according to the subject in the context. The
|
||||
* subject can be either a user or an application.
|
||||
*
|
||||
* When both `userId` and `applicationId` are provided, the function will prioritize the user.
|
||||
*
|
||||
* This function also handles the reserved resources.
|
||||
*
|
||||
|
@ -40,6 +43,15 @@ export const findResourceScopes = async ({
|
|||
queries: Queries;
|
||||
libraries: Libraries;
|
||||
indicator: string;
|
||||
/**
|
||||
* In consent or code exchange flow, the `organizationId` is `undefined`, and all the scopes
|
||||
* inherited from the all organization roles should be granted.
|
||||
*
|
||||
* In the flow of granting token for a specific organization with API resource, `organizationId`
|
||||
* is provided, and only the scopes inherited from that organization should be granted.
|
||||
*
|
||||
* Note: This value does not affect the reserved resources and application subjects.
|
||||
*/
|
||||
findFromOrganizations: boolean;
|
||||
userId?: string;
|
||||
applicationId?: string;
|
||||
|
@ -68,6 +80,14 @@ export const findResourceScopes = async ({
|
|||
);
|
||||
}
|
||||
|
||||
if (applicationId && organizationId) {
|
||||
return queries.organizations.relations.appsRoles.getApplicationResourceScopes(
|
||||
organizationId,
|
||||
applicationId,
|
||||
indicator
|
||||
);
|
||||
}
|
||||
|
||||
if (applicationId) {
|
||||
return findApplicationScopesForResourceIndicator(applicationId, indicator);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,13 @@ import {
|
|||
OrganizationRoles,
|
||||
Applications,
|
||||
OrganizationRoleApplicationRelations,
|
||||
type OrganizationScope,
|
||||
OrganizationRoleScopeRelations,
|
||||
OrganizationScopes,
|
||||
OrganizationRoleResourceScopeRelations,
|
||||
Scopes,
|
||||
Resources,
|
||||
type Scope,
|
||||
} from '@logto/schemas';
|
||||
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
||||
|
||||
|
@ -22,6 +29,61 @@ export class ApplicationRoleRelationQueries extends RelationQueries<
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the organization scopes of an application in an organization. Scopes are unique by
|
||||
* their IDs.
|
||||
*/
|
||||
async getApplicationScopes(
|
||||
organizationId: string,
|
||||
applicationId: string
|
||||
): Promise<readonly OrganizationScope[]> {
|
||||
const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true);
|
||||
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
|
||||
const scopes = convertToIdentifiers(OrganizationScopes, true);
|
||||
|
||||
return this.pool.any<OrganizationScope>(sql`
|
||||
select distinct on (${scopes.fields.id})
|
||||
${sql.join(Object.values(scopes.fields), sql`, `)}
|
||||
from ${this.table}
|
||||
join ${roleScopeRelations.table}
|
||||
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
|
||||
join ${scopes.table}
|
||||
on ${scopes.fields.id} = ${roleScopeRelations.fields.organizationScopeId}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
and ${fields.applicationId} = ${applicationId}
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the resource scopes of an application in an organization. Scopes are unique by their
|
||||
* IDs.
|
||||
*/
|
||||
async getApplicationResourceScopes(
|
||||
organizationId: string,
|
||||
applicationId: string,
|
||||
resourceIndicator: string
|
||||
): Promise<readonly Scope[]> {
|
||||
const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true);
|
||||
const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true);
|
||||
const resources = convertToIdentifiers(Resources, true);
|
||||
const scopes = convertToIdentifiers(Scopes, true);
|
||||
|
||||
return this.pool.any<Scope>(sql`
|
||||
select distinct on (${scopes.fields.id})
|
||||
${sql.join(Object.values(scopes.fields), sql`, `)}
|
||||
from ${this.table}
|
||||
join ${roleScopeRelations.table}
|
||||
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
|
||||
join ${scopes.table}
|
||||
on ${scopes.fields.id} = ${roleScopeRelations.fields.scopeId}
|
||||
join ${resources.table}
|
||||
on ${resources.fields.id} = ${scopes.fields.resourceId}
|
||||
where ${fields.organizationId} = ${organizationId}
|
||||
and ${fields.applicationId} = ${applicationId}
|
||||
and ${resources.fields.indicator} = ${resourceIndicator}
|
||||
`);
|
||||
}
|
||||
|
||||
/** Replace the roles of an application in an organization. */
|
||||
async replace(organizationId: string, applicationId: string, roleIds: readonly string[]) {
|
||||
const applications = convertToIdentifiers(Applications);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { formUrlEncodedHeaders } from '@logto/shared';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
import ky from 'ky';
|
||||
|
||||
|
@ -31,5 +32,6 @@ export const cloudApi = ky.extend({
|
|||
});
|
||||
|
||||
export const oidcApi = ky.extend({
|
||||
headers: formUrlEncodedHeaders,
|
||||
prefixUrl: appendPath(new URL(logtoUrl), 'oidc'),
|
||||
});
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
type ProtectedAppMetadata,
|
||||
type OrganizationWithRoles,
|
||||
} from '@logto/schemas';
|
||||
import { formUrlEncodedHeaders } from '@logto/shared';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import { authedAdminApi, oidcApi } from './api.js';
|
||||
|
@ -101,7 +100,6 @@ export const generateM2mLog = async (applicationId: string) => {
|
|||
|
||||
// This is a token request with insufficient parameters and should fail. We make the request to generate a log for the current machine to machine app.
|
||||
return oidcApi.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: id,
|
||||
client_secret: secret,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ApplicationType, RoleType } from '@logto/schemas';
|
||||
import { generateStandardId, formUrlEncodedHeaders } from '@logto/shared';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { HTTPError } from 'ky';
|
||||
|
||||
import {
|
||||
|
@ -175,7 +175,6 @@ describe('admin console application management (roles)', () => {
|
|||
|
||||
const { access_token: accessToken } = await oidcApi
|
||||
.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: m2mApp.id,
|
||||
client_secret: m2mApp.secret,
|
||||
|
|
|
@ -0,0 +1,300 @@
|
|||
import assert from 'node:assert';
|
||||
|
||||
import { buildOrganizationUrn } from '@logto/core-kit';
|
||||
import {
|
||||
type Application,
|
||||
ApplicationType,
|
||||
RoleType,
|
||||
type Resource,
|
||||
type Role,
|
||||
type Scope,
|
||||
} from '@logto/schemas';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { HTTPError } from 'ky';
|
||||
|
||||
import { oidcApi } from '#src/api/api.js';
|
||||
import {
|
||||
assignRolesToApplication,
|
||||
createApplication,
|
||||
deleteApplication,
|
||||
} from '#src/api/application.js';
|
||||
import {
|
||||
createResource as createResourceApi,
|
||||
deleteResource,
|
||||
setDefaultResource,
|
||||
} from '#src/api/resource.js';
|
||||
import { assignScopesToRole, createRole as createRoleApi, deleteRole } from '#src/api/role.js';
|
||||
import { createScope as createScopeApi } from '#src/api/scope.js';
|
||||
import { isDevFeaturesEnabled, logtoUrl } from '#src/constants.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import { devFeatureTest, randomString } from '#src/utils.js';
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
const createApi = <R, Args extends unknown[]>(
|
||||
run: (...args: Args) => Promise<R>,
|
||||
storage: R[]
|
||||
): ((...args: Args) => Promise<R>) => {
|
||||
return async (...args) => {
|
||||
const result = await run(...args);
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
storage.push(result);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
describe('client credentials grant', () => {
|
||||
const jwkSet = createRemoteJWKSet(appendPath(new URL(logtoUrl), 'oidc/jwks'));
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let client: Application;
|
||||
const currentResources: Resource[] = [];
|
||||
const currentRoles: Role[] = [];
|
||||
const currentScopes: Scope[] = [];
|
||||
|
||||
const createResource = createApi(createResourceApi, currentResources);
|
||||
const createRole = createApi(createRoleApi, currentRoles);
|
||||
const createScope = createApi(createScopeApi, currentScopes);
|
||||
|
||||
const post = async (additionalParams: Record<string, string> = {}) =>
|
||||
oidcApi
|
||||
.post('token', {
|
||||
body: new URLSearchParams({
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
grant_type: 'client_credentials',
|
||||
...additionalParams,
|
||||
}),
|
||||
})
|
||||
.json<TokenResponse>();
|
||||
|
||||
const expectError = async (
|
||||
additionalParams: Record<string, string>,
|
||||
status: number,
|
||||
json?: Record<string, unknown>
|
||||
) => {
|
||||
const error = await post(additionalParams).catch((error: unknown) => error);
|
||||
assert(error instanceof HTTPError);
|
||||
expect(error.response.status).toBe(status);
|
||||
|
||||
if (json) {
|
||||
expect(await error.response.json()).toMatchObject(json);
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
client = await createApplication('client credentials test', ApplicationType.MachineToMachine);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteApplication(client.id);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([
|
||||
organizationApi.cleanUp(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
...currentResources.map(async ({ id }) => deleteResource(id).catch(() => {})),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
...currentRoles.map(async ({ id }) => deleteRole(`roles/${id}`).catch(() => {})),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('general failed cases', () => {
|
||||
it('should fail if the client is not found', async () => {
|
||||
await expectError({ client_id: 'not-found', client_secret: 'not-found' }, 400, {
|
||||
error: 'invalid_client',
|
||||
error_description: 'invalid client not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if parameters are missing', async () => {
|
||||
await expectError({}, 400, {
|
||||
error: 'invalid_target',
|
||||
error_description: 'both `resource` and `organization_id` are not provided',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource server', () => {
|
||||
it('should fail if the resource server is not found', async () => {
|
||||
await expectError({ resource: 'https://not-found' }, 400, {
|
||||
error: 'invalid_target',
|
||||
error_description: 'resource indicator is missing, or unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to get a token for a resource server', async () => {
|
||||
const resource = await createResource();
|
||||
const { access_token: accessToken, scope } = await post({ resource: resource.indicator });
|
||||
|
||||
expect(scope).toBe(undefined);
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator });
|
||||
expect(verified.payload.scope).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should be able to get a token for a resource server with valid scope', async () => {
|
||||
const resource = await createResource();
|
||||
const scope = await createScope(resource.id, 'test-scope');
|
||||
const role = await createRole({
|
||||
name: `cc-${randomString()}`,
|
||||
type: RoleType.MachineToMachine,
|
||||
});
|
||||
await assignScopesToRole([scope.id], role.id);
|
||||
await assignRolesToApplication(client.id, [role.id]);
|
||||
|
||||
const { access_token: accessToken, scope: returnedScope } = await post({
|
||||
resource: resource.indicator,
|
||||
scope: `${scope.name} ${randomString()}`,
|
||||
});
|
||||
|
||||
expect(returnedScope).toBe(scope.name);
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator });
|
||||
expect(verified.payload.scope).toBe(scope.name);
|
||||
});
|
||||
|
||||
it('should fall back to the default resource server if no `resource` parameter is provided', async () => {
|
||||
const resource = await createResource();
|
||||
await setDefaultResource(resource.id);
|
||||
const { access_token: accessToken, scope } = await post();
|
||||
|
||||
expect(scope).toBe(undefined);
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator });
|
||||
expect(verified.payload.scope).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('organization token', () => {
|
||||
it('should fail if dev feature is not enabled', async () => {
|
||||
if (isDevFeaturesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expectError({ organization_id: 'not-found' }, 400, {
|
||||
error: 'invalid_target',
|
||||
error_description: 'organization tokens are not supported yet',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
devFeatureTest.describe('organization token', () => {
|
||||
it('should fail if the application is not associated with the organization', async () => {
|
||||
await expectError({ organization_id: 'not-found' }, 403, {
|
||||
error: 'access_denied',
|
||||
error_description: 'app has not associated with the organization',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to get an organization token', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test-organization' });
|
||||
await organizationApi.applications.add(organization.id, [client.id]);
|
||||
const { access_token: accessToken, scope } = await post({ organization_id: organization.id });
|
||||
|
||||
expect(scope).toBe('');
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, {
|
||||
audience: buildOrganizationUrn(organization.id),
|
||||
});
|
||||
expect(verified.payload.scope).toBe('');
|
||||
});
|
||||
|
||||
it('should be able to get an organization token with valid scope', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test-organization' });
|
||||
await organizationApi.applications.add(organization.id, [client.id]);
|
||||
const scope = await organizationApi.scopeApi.create({ name: `test-scope-${randomString()}` });
|
||||
const role = await organizationApi.roleApi.create({
|
||||
name: `cc-${randomString()}`,
|
||||
type: RoleType.MachineToMachine,
|
||||
organizationScopeIds: [scope.id],
|
||||
});
|
||||
await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]);
|
||||
|
||||
const { access_token: accessToken, scope: returnedScope } = await post({
|
||||
organization_id: organization.id,
|
||||
scope: `${scope.name} ${randomString()}`,
|
||||
});
|
||||
|
||||
expect(returnedScope).toBe(scope.name);
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, {
|
||||
audience: buildOrganizationUrn(organization.id),
|
||||
});
|
||||
expect(verified.payload.scope).toBe(scope.name);
|
||||
});
|
||||
|
||||
it('should be able to get an organization token with valid scope and resource', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test-organization' });
|
||||
await organizationApi.applications.add(organization.id, [client.id]);
|
||||
|
||||
const resource = await createResource();
|
||||
const [scope1, scope2, scope3] = await Promise.all([
|
||||
createScope(resource.id, `test-scope-${randomString()}`),
|
||||
createScope(resource.id, `test-scope-${randomString()}`),
|
||||
createScope(resource.id, `test-scope-${randomString()}`),
|
||||
]);
|
||||
const role = await organizationApi.roleApi.create({
|
||||
name: `cc-${randomString()}`,
|
||||
type: RoleType.MachineToMachine,
|
||||
resourceScopeIds: [scope1.id, scope2.id],
|
||||
});
|
||||
await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]);
|
||||
|
||||
const { access_token: accessToken, scope: returnedScope } = await post({
|
||||
organization_id: organization.id,
|
||||
resource: resource.indicator,
|
||||
scope: `${scope1.name} ${scope2.name} ${scope3.name} ${randomString()}`,
|
||||
});
|
||||
expect(returnedScope).toBe(`${scope1.name} ${scope2.name}`);
|
||||
|
||||
const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator });
|
||||
expect(verified.payload.scope).toBe(`${scope1.name} ${scope2.name}`);
|
||||
});
|
||||
|
||||
it('should only issue requested scopes', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test-organization' });
|
||||
await organizationApi.applications.add(organization.id, [client.id]);
|
||||
|
||||
const resource = await createResource();
|
||||
const [scope1, scope2] = await Promise.all([
|
||||
createScope(resource.id, `test-scope-${randomString()}`),
|
||||
createScope(resource.id, `test-scope-${randomString()}`),
|
||||
]);
|
||||
const role = await organizationApi.roleApi.create({
|
||||
name: `cc-${randomString()}`,
|
||||
type: RoleType.MachineToMachine,
|
||||
resourceScopeIds: [scope1.id, scope2.id],
|
||||
});
|
||||
await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]);
|
||||
|
||||
const { access_token: accessToken1, scope: returnedScope1 } = await post({
|
||||
organization_id: organization.id,
|
||||
resource: resource.indicator,
|
||||
scope: `${scope1.name}`,
|
||||
});
|
||||
expect(returnedScope1).toBe(scope1.name);
|
||||
|
||||
const verified1 = await jwtVerify(accessToken1, jwkSet, { audience: resource.indicator });
|
||||
expect(verified1.payload.scope).toBe(scope1.name);
|
||||
|
||||
const { access_token: accessToken2, scope: returnedScope2 } = await post({
|
||||
organization_id: organization.id,
|
||||
resource: resource.indicator,
|
||||
scope: '',
|
||||
});
|
||||
expect(returnedScope2).toBe(undefined);
|
||||
|
||||
const verified2 = await jwtVerify(accessToken2, jwkSet, { audience: resource.indicator });
|
||||
expect(verified2.payload.scope).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue