diff --git a/.changeset/gentle-camels-film.md b/.changeset/gentle-camels-film.md
new file mode 100644
index 000000000..35903a01a
--- /dev/null
+++ b/.changeset/gentle-camels-film.md
@@ -0,0 +1,29 @@
+---
+"@logto/console": minor
+"@logto/core": minor
+"@logto/phrases": minor
+"@logto/schemas": minor
+"@logto/integration-tests": patch
+---
+
+support machine-to-machine apps for organizations
+
+This feature allows machine-to-machine apps to be associated with organizations, and be assigned with organization roles.
+
+### Console
+
+- Add a new "machine-to-machine" type to organization roles. All existing roles are now "user" type.
+- You can manage machine-to-machine apps in the organization details page -> Machine-to-machine apps section.
+- You can view the associated organizations in the machine-to-machine app details page.
+
+### OpenID Connect grant
+
+The `client_credentials` grant type is now supported for organizations. You can use this grant type to obtain an access token for an organization.
+
+### Management API
+
+A set of new endpoints are added to the Management API:
+
+- `/api/organizations/{id}/applications` to manage machine-to-machine apps.
+- `/api/organizations/{id}/applications/{applicationId}` to manage a specific machine-to-machine app in an organization.
+- `/api/applications/{id}/organizations` to view the associated organizations of a machine-to-machine app.
diff --git a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx
index cb6e5d69d..1c70a6738 100644
--- a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx
+++ b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx
@@ -1,7 +1,6 @@
import { condArray } from '@silverhand/essentials';
import { Navigate, type RouteObject } from 'react-router-dom';
-import { isDevFeaturesEnabled } from '@/consts/env';
import OrganizationDetails from '@/pages/OrganizationDetails';
import MachineToMachine from '@/pages/OrganizationDetails/MachineToMachine';
import Members from '@/pages/OrganizationDetails/Members';
@@ -17,15 +16,15 @@ export const organizations: RouteObject = {
{
path: ':id/*',
element: ,
- children: condArray(
+ children: [
{ index: true, element: },
{ path: OrganizationDetailsTabs.Settings, element: },
{ path: OrganizationDetailsTabs.Members, element: },
- isDevFeaturesEnabled && {
+ {
path: OrganizationDetailsTabs.MachineToMachine,
element: ,
- }
- ),
+ },
+ ],
}
),
};
diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx
index ccf9522ff..27924e718 100644
--- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx
+++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx
@@ -19,7 +19,6 @@ import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import OrganizationList from '@/components/OrganizationList';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { ApplicationDetailsTabs, logtoThirdPartyGuideLink, protectedAppLink } from '@/consts';
-import { isDevFeaturesEnabled } from '@/consts/env';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';
@@ -178,11 +177,9 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
{t('application_details.machine_logs')}
- {isDevFeaturesEnabled && (
-
- {t('organizations.title')}
-
- )}
+
+ {t('organizations.title')}
+
>
)}
{data.isThirdParty && (
diff --git a/packages/console/src/pages/OrganizationDetails/index.tsx b/packages/console/src/pages/OrganizationDetails/index.tsx
index 265dbd050..ddf858181 100644
--- a/packages/console/src/pages/OrganizationDetails/index.tsx
+++ b/packages/console/src/pages/OrganizationDetails/index.tsx
@@ -19,7 +19,6 @@ import Skeleton from '@/components/DetailsPage/Skeleton';
import Drawer from '@/components/Drawer';
import PageMeta from '@/components/PageMeta';
import ThemedIcon from '@/components/ThemedIcon';
-import { isDevFeaturesEnabled } from '@/consts/env';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import useApi, { type RequestError } from '@/hooks/use-api';
@@ -134,12 +133,10 @@ function OrganizationDetails() {
{t('organizations.members')}
- {/* TODO: Remove */}
- {isDevFeaturesEnabled && (
-
- {t('organizations.machine_to_machine')}
-
- )}
+
+
+ {t('organizations.machine_to_machine')}
+
- {/* TODO: Remove */}
- {isDevFeaturesEnabled && (
-
- (
- {
- onChange(value);
- }}
- >
- {radioOptions.map(({ key, value }) => (
- } value={value} />
- ))}
-
- )}
- />
-
- )}
+
+ (
+ {
+ onChange(value);
+ }}
+ >
+ {radioOptions.map(({ key, value }) => (
+ } value={value} />
+ ))}
+
+ )}
+ />
+
);
diff --git a/packages/core/src/oidc/grants/client-credentials.ts b/packages/core/src/oidc/grants/client-credentials.ts
index 12a12225e..5bf06f889 100644
--- a/packages/core/src/oidc/grants/client-credentials.ts
+++ b/packages/core/src/oidc/grants/client-credentials.ts
@@ -28,7 +28,7 @@ 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 { EnvSet } from '#src/env-set/index.js';
+import { type EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
@@ -68,10 +68,6 @@ export const buildHandler: (
// 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 &&
diff --git a/packages/core/src/routes/applications/application-organization.openapi.json b/packages/core/src/routes/applications/application-organization.openapi.json
index 9edad9907..3ceb28b52 100644
--- a/packages/core/src/routes/applications/application-organization.openapi.json
+++ b/packages/core/src/routes/applications/application-organization.openapi.json
@@ -2,7 +2,6 @@
"paths": {
"/api/applications/{id}/organizations": {
"get": {
- "tags": ["Dev feature"],
"summary": "Get application organizations",
"description": "Get the list of organizations that an application is associated with.",
"responses": {
diff --git a/packages/core/src/routes/applications/application-organization.ts b/packages/core/src/routes/applications/application-organization.ts
index 79a30d9a8..79e5c3bba 100644
--- a/packages/core/src/routes/applications/application-organization.ts
+++ b/packages/core/src/routes/applications/application-organization.ts
@@ -1,7 +1,6 @@
import { organizationWithOrganizationRolesGuard } from '@logto/schemas';
import { z } from 'zod';
-import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@@ -10,11 +9,6 @@ import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
export default function applicationOrganizationRoutes(
...[router, { queries }]: RouterInitArgs
) {
- // TODO: Remove
- if (!EnvSet.values.isDevFeaturesEnabled) {
- return;
- }
-
router.get(
'/applications/:id/organizations',
koaPagination({ isOptional: true }),
diff --git a/packages/core/src/routes/organization/application/index.openapi.json b/packages/core/src/routes/organization/application/index.openapi.json
index 00dc4b8d4..ea9390633 100644
--- a/packages/core/src/routes/organization/application/index.openapi.json
+++ b/packages/core/src/routes/organization/application/index.openapi.json
@@ -3,8 +3,7 @@
{
"name": "Organization applications",
"description": "Manage organization - application relationships. An application can be associated with one or more organizations in order to get access to the organization resources.\n\nCurrently, only machine-to-machine applications can be associated with organizations."
- },
- { "name": "Dev feature" }
+ }
],
"paths": {
"/api/organizations/{id}/applications": {
@@ -81,7 +80,6 @@
},
"/api/organizations/{id}/applications/roles": {
"post": {
- "tags": ["Dev feature"],
"summary": "Assign roles to applications in an organization",
"description": "Assign roles to applications in the specified organization.",
"requestBody": {
diff --git a/packages/core/src/routes/organization/application/index.ts b/packages/core/src/routes/organization/application/index.ts
index 94f107256..cc44d46f4 100644
--- a/packages/core/src/routes/organization/application/index.ts
+++ b/packages/core/src/routes/organization/application/index.ts
@@ -6,7 +6,6 @@ import {
} from '@logto/schemas';
import { z } from 'zod';
-import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { applicationSearchKeys } from '#src/queries/application.js';
@@ -21,69 +20,67 @@ export default function applicationRoutes(
router: SchemaRouter,
organizations: OrganizationQueries
) {
- if (EnvSet.values.isDevFeaturesEnabled) {
- // MARK: Organization - application relation routes
- router.addRelationRoutes(organizations.relations.apps, undefined, {
- disabled: { get: true },
- hookEvent: 'Organization.Membership.Updated',
- });
+ // MARK: Organization - application relation routes
+ router.addRelationRoutes(organizations.relations.apps, undefined, {
+ disabled: { get: true },
+ hookEvent: 'Organization.Membership.Updated',
+ });
- router.get(
- '/:id/applications',
- koaPagination(),
- koaGuard({
- query: z.object({ q: z.string().optional() }),
- params: z.object({ id: z.string().min(1) }),
- response: applicationWithOrganizationRolesGuard.array(),
- status: [200, 404],
- }),
- async (ctx, next) => {
- const search = parseSearchOptions(applicationSearchKeys, ctx.guard.query);
+ router.get(
+ '/:id/applications',
+ koaPagination(),
+ koaGuard({
+ query: z.object({ q: z.string().optional() }),
+ params: z.object({ id: z.string().min(1) }),
+ response: applicationWithOrganizationRolesGuard.array(),
+ status: [200, 404],
+ }),
+ async (ctx, next) => {
+ const search = parseSearchOptions(applicationSearchKeys, ctx.guard.query);
- const [totalCount, entities] =
- await organizations.relations.apps.getApplicationsByOrganizationId(
- ctx.guard.params.id,
- ctx.pagination,
- search
- );
-
- ctx.pagination.totalCount = totalCount;
- ctx.body = entities;
-
- return next();
- }
- );
-
- router.post(
- '/:id/applications/roles',
- koaGuard({
- params: z.object({ id: z.string().min(1) }),
- body: z.object({
- applicationIds: z.string().min(1).array().nonempty(),
- organizationRoleIds: z.string().min(1).array().nonempty(),
- }),
- status: [201, 422],
- }),
- async (ctx, next) => {
- const { id } = ctx.guard.params;
- const { applicationIds, organizationRoleIds } = ctx.guard.body;
-
- await organizations.relations.appsRoles.insert(
- ...organizationRoleIds.flatMap((organizationRoleId) =>
- applicationIds.map((applicationId) => ({
- organizationId: id,
- applicationId,
- organizationRoleId,
- }))
- )
+ const [totalCount, entities] =
+ await organizations.relations.apps.getApplicationsByOrganizationId(
+ ctx.guard.params.id,
+ ctx.pagination,
+ search
);
- ctx.status = 201;
- return next();
- }
- );
+ ctx.pagination.totalCount = totalCount;
+ ctx.body = entities;
- // MARK: Organization - application role relation routes
- applicationRoleRelationRoutes(router, organizations);
- }
+ return next();
+ }
+ );
+
+ router.post(
+ '/:id/applications/roles',
+ koaGuard({
+ params: z.object({ id: z.string().min(1) }),
+ body: z.object({
+ applicationIds: z.string().min(1).array().nonempty(),
+ organizationRoleIds: z.string().min(1).array().nonempty(),
+ }),
+ status: [201, 422],
+ }),
+ async (ctx, next) => {
+ const { id } = ctx.guard.params;
+ const { applicationIds, organizationRoleIds } = ctx.guard.body;
+
+ await organizations.relations.appsRoles.insert(
+ ...organizationRoleIds.flatMap((organizationRoleId) =>
+ applicationIds.map((applicationId) => ({
+ organizationId: id,
+ applicationId,
+ organizationRoleId,
+ }))
+ )
+ );
+
+ ctx.status = 201;
+ return next();
+ }
+ );
+
+ // MARK: Organization - application role relation routes
+ applicationRoleRelationRoutes(router, organizations);
}
diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts
index d203744a7..b27831f8c 100644
--- a/packages/core/src/routes/swagger/index.ts
+++ b/packages/core/src/routes/swagger/index.ts
@@ -150,7 +150,7 @@ const identifiableEntityNames = Object.freeze([
/** Additional tags that cannot be inferred from the path. */
const additionalTags = Object.freeze(
condArray(
- EnvSet.values.isDevFeaturesEnabled && 'Organization applications',
+ 'Organization applications',
EnvSet.values.isDevFeaturesEnabled && 'Security',
'Organization users'
)
diff --git a/packages/integration-tests/src/tests/api/application/application.organization.test.ts b/packages/integration-tests/src/tests/api/application/application.organization.test.ts
index 96c9ea878..047fd0f7f 100644
--- a/packages/integration-tests/src/tests/api/application/application.organization.test.ts
+++ b/packages/integration-tests/src/tests/api/application/application.organization.test.ts
@@ -7,9 +7,9 @@ import {
getOrganizations,
} from '#src/api/application.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
-import { devFeatureTest, generateTestName } from '#src/utils.js';
+import { generateTestName } from '#src/utils.js';
-devFeatureTest.describe('application organizations', () => {
+describe('application organizations', () => {
const organizationApi = new OrganizationApiTest();
const applications: Application[] = [];
const createApplication = async (...args: Parameters) => {
diff --git a/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts
index 63c5dbacc..530862136 100644
--- a/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts
+++ b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts
@@ -26,9 +26,9 @@ import {
} 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 { logtoUrl } from '#src/constants.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
-import { devFeatureTest, randomString } from '#src/utils.js';
+import { randomString } from '#src/utils.js';
type TokenResponse = {
access_token: string;
@@ -175,19 +175,6 @@ describe('client credentials grant', () => {
});
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',
diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts
index b4ae58698..44c7b2f6a 100644
--- a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts
+++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts
@@ -13,10 +13,9 @@ import {
deleteApplication,
} from '#src/api/application.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
-import { devFeatureTest, generateTestName } from '#src/utils.js';
+import { generateTestName } from '#src/utils.js';
-// TODO: Remove this prefix
-devFeatureTest.describe('organization application APIs', () => {
+describe('organization application APIs', () => {
describe('organization get applications', () => {
const organizationApi = new OrganizationApiTest();
const applications: Application[] = [];
diff --git a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts
index 6247ff01e..26604555f 100644
--- a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts
+++ b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts
@@ -5,7 +5,7 @@ import { HTTPError } from 'ky';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { UserApiTest } from '#src/helpers/user.js';
-import { devFeatureTest, generateTestName } from '#src/utils.js';
+import { generateTestName } from '#src/utils.js';
describe('organization user APIs', () => {
describe('organization get users', () => {
@@ -289,7 +289,7 @@ describe('organization user APIs', () => {
);
});
- devFeatureTest.it('should fail when try to add role that is not user type', async () => {
+ it('should fail when try to add role that is not user type', async () => {
const organization = await organizationApi.create({ name: 'test' });
const user = await userApi.create({ username: generateTestName() });
const role = await roleApi.create({