diff --git a/.changeset/stale-planets-sneeze.md b/.changeset/stale-planets-sneeze.md
new file mode 100644
index 000000000..ce4b627fa
--- /dev/null
+++ b/.changeset/stale-planets-sneeze.md
@@ -0,0 +1,18 @@
+---
+"@logto/console": minor
+"@logto/schemas": minor
+"@logto/core": minor
+"@logto/integration-tests": patch
+"@logto/phrases": patch
+---
+
+support multiple app secrets with expiration
+
+Now secure apps (machine-to-machine, traditional web, Protected) can have multiple app secrets with expiration. This allows for secret rotation and provides an even safer experience.
+
+To manage your application secrets, go to Logto Console -> Applications -> Application Details -> Endpoints & Credentials.
+
+We've also added a set of Management APIs (`/api/applications/{id}/secrets`) for this purpose.
+
+> [!Important]
+> You can still use existing app secrets for client authentication, but it is recommended to delete the old ones and create new secrets with expiration for enhanced security.
diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials.tsx
index 512131c31..942145a91 100644
--- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials.tsx
+++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/EndpointsAndCredentials.tsx
@@ -17,7 +17,6 @@ import CirclePlus from '@/assets/icons/circle-plus.svg?react';
import Plus from '@/assets/icons/plus.svg?react';
import ActionsButton from '@/components/ActionsButton';
import FormCard from '@/components/FormCard';
-import { isDevFeaturesEnabled } from '@/consts/env';
import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc';
import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button';
@@ -239,17 +238,7 @@ function EndpointsAndCredentials({
- {!isDevFeaturesEnabled && shouldShowAppSecrets && (
-
-
-
- )}
- {isDevFeaturesEnabled && shouldShowAppSecrets && (
+ {shouldShowAppSecrets && (
{secretsData.length === 0 && !secrets.error ? (
<>
diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts
index d17bc4fc9..b4a7b94a3 100644
--- a/packages/core/src/oidc/init.ts
+++ b/packages/core/src/oidc/init.ts
@@ -22,7 +22,7 @@ import Provider, { errors } from 'oidc-provider';
import getRawBody from 'raw-body';
import snakecaseKeys from 'snakecase-keys';
-import { EnvSet } from '#src/env-set/index.js';
+import { type EnvSet } from '#src/env-set/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
@@ -410,10 +410,7 @@ export default function initOidc(
return next();
});
- if (EnvSet.values.isDevFeaturesEnabled) {
- oidc.use(koaAppSecretTranspilation(queries));
- }
-
+ oidc.use(koaAppSecretTranspilation(queries));
oidc.use(koaBodyEtag());
return oidc;
diff --git a/packages/core/src/routes/applications/application-secret.openapi.json b/packages/core/src/routes/applications/application-secret.openapi.json
index 7b57dd71e..ea8be00c0 100644
--- a/packages/core/src/routes/applications/application-secret.openapi.json
+++ b/packages/core/src/routes/applications/application-secret.openapi.json
@@ -1,9 +1,4 @@
{
- "tags": [
- {
- "name": "Dev feature"
- }
- ],
"paths": {
"/api/applications/{id}/legacy-secret": {
"delete": {
diff --git a/packages/core/src/routes/applications/application.openapi.json b/packages/core/src/routes/applications/application.openapi.json
index 62d2503c9..95fbee1e3 100644
--- a/packages/core/src/routes/applications/application.openapi.json
+++ b/packages/core/src/routes/applications/application.openapi.json
@@ -5,6 +5,15 @@
"description": "Application represents your registered software program or service that has been authorized to access user information and perform actions on behalf of users within the system. Currently, Logto supports four types of applications:\n\n- Traditional web\n\n- Single-page app\n- Native app\n- Machine-to-machine app.\n\nDepending on the application type, it may have different authentication flows and access to the system. See [🔗 Integrate Logto in your application](https://docs.logto.io/docs/recipes/integrate-logto/) to learn more about how to integrate Logto into your application.\n\nRole-based access control (RBAC) is supported for machine-to-machine applications. See [🔐 Role-based access control (RBAC)](https://docs.logto.io/docs/recipes/rbac/) to get started with role-based access control."
}
],
+ "components": {
+ "schemas": {
+ "ApplicationLegacySecret": {
+ "type": "string",
+ "deprecated": true,
+ "description": "The internal client secret. Note it is only used for internal validation, and the actual secrets should be retrieved from `/api/applications/{id}/secrets` endpoints."
+ }
+ }
+ },
"paths": {
"/api/applications": {
"get": {
@@ -19,7 +28,21 @@
],
"responses": {
"200": {
- "description": "A list of applications."
+ "description": "A list of applications.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "secret": {
+ "$ref": "#/components/schemas/ApplicationLegacySecret"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
},
@@ -49,7 +72,18 @@
},
"responses": {
"200": {
- "description": "The application was created successfully."
+ "description": "The application was created successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "secret": {
+ "$ref": "#/components/schemas/ApplicationLegacySecret"
+ }
+ }
+ }
+ }
+ }
},
"422": {
"description": "Validation error. Please check the request body."
@@ -63,7 +97,18 @@
"description": "Get application details by ID.",
"responses": {
"200": {
- "description": "Details of the application."
+ "description": "Details of the application.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "secret": {
+ "$ref": "#/components/schemas/ApplicationLegacySecret"
+ }
+ }
+ }
+ }
+ }
},
"404": {
"description": "The application with the specified ID was not found."
@@ -88,7 +133,18 @@
},
"responses": {
"200": {
- "description": "The application was updated successfully."
+ "description": "The application was updated successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "secret": {
+ "$ref": "#/components/schemas/ApplicationLegacySecret"
+ }
+ }
+ }
+ }
+ }
},
"404": {
"description": "The application with the specified ID was not found."
diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts
index a855aca49..535fd2583 100644
--- a/packages/core/src/routes/applications/application.ts
+++ b/packages/core/src/routes/applications/application.ts
@@ -182,11 +182,9 @@ export default function applicationRoutes(
);
}
- const getSecret = () =>
- EnvSet.values.isDevFeaturesEnabled ? generateInternalSecret() : generateStandardSecret();
const application = await queries.applications.insertApplication({
id: generateStandardId(),
- secret: getSecret(),
+ secret: generateInternalSecret(),
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
...conditional(
rest.type === ApplicationType.Protected &&
@@ -196,7 +194,7 @@ export default function applicationRoutes(
...rest,
});
- if (EnvSet.values.isDevFeaturesEnabled && hasSecrets(application.type)) {
+ if (hasSecrets(application.type)) {
await queries.applicationSecrets.insert({
name: 'Default secret',
applicationId: application.id,
diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts
index 868e6b30c..48eb5c32b 100644
--- a/packages/core/src/routes/init.ts
+++ b/packages/core/src/routes/init.ts
@@ -66,9 +66,7 @@ const createRouters = (tenant: TenantContext) => {
applicationRoleRoutes(managementRouter, tenant);
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
applicationOrganizationRoutes(managementRouter, tenant);
- if (EnvSet.values.isDevFeaturesEnabled) {
- applicationSecretRoutes(managementRouter, tenant);
- }
+ applicationSecretRoutes(managementRouter, tenant);
// Third-party application related routes
applicationUserConsentScopeRoutes(managementRouter, tenant);
diff --git a/packages/core/src/routes/organization/application/index.openapi.json b/packages/core/src/routes/organization/application/index.openapi.json
index ea9390633..12c181190 100644
--- a/packages/core/src/routes/organization/application/index.openapi.json
+++ b/packages/core/src/routes/organization/application/index.openapi.json
@@ -12,7 +12,21 @@
"description": "Get applications associated with the organization.",
"responses": {
"200": {
- "description": "A list of applications."
+ "description": "A list of applications.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "secret": {
+ "$ref": "#/components/schemas/ApplicationLegacySecret"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
},
diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts
index cfaf39a9e..0ce1172bb 100644
--- a/packages/core/src/routes/swagger/index.ts
+++ b/packages/core/src/routes/swagger/index.ts
@@ -27,6 +27,7 @@ import {
devFeatureTag,
findSupplementFiles,
normalizePath,
+ pruneSwaggerDocument,
removeUnnecessaryOperations,
shouldThrow,
validateSupplement,
@@ -299,6 +300,8 @@ export default function swaggerRoutes !EnvSet.values.isProduction || EnvSet.values.isIntegrationTest;
+
+/**
+ * Remove all other properties when "$ref" is present in an object. Supplemental documents may
+ * contain "$ref" properties, which all other properties should be removed to prevent conflicts.
+ *
+ * **CAUTION**: This function mutates the input document.
+ */
+export const pruneSwaggerDocument = (document: OpenAPIV3.Document) => {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const prune = (object: {}) => {
+ if (isKeyInObject(object, '$ref')) {
+ for (const key of Object.keys(object)) {
+ if (key !== '$ref') {
+ // @ts-expect-error -- intended
+ // eslint-disable-next-line @silverhand/fp/no-delete, @typescript-eslint/no-dynamic-delete
+ delete object[key];
+ }
+ }
+ }
+
+ for (const value of Object.values(object)) {
+ if (isObject(value)) {
+ prune(value);
+ }
+ }
+ };
+
+ prune(document);
+};
diff --git a/packages/integration-tests/src/tests/api/application/application.secrets.test.ts b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts
index 3821be234..40962bccc 100644
--- a/packages/integration-tests/src/tests/api/application/application.secrets.test.ts
+++ b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts
@@ -9,11 +9,11 @@ import {
deleteApplicationSecret,
getApplicationSecrets,
} from '#src/api/application.js';
-import { devFeatureTest, randomString } from '#src/utils.js';
+import { randomString } from '#src/utils.js';
const defaultSecretName = 'Default secret';
-devFeatureTest.describe('application secrets', () => {
+describe('application secrets', () => {
const applications: Application[] = [];
const createApplication = async (...args: Parameters) => {
const created = await createApplicationApi(...args);
diff --git a/packages/schemas/tables/applications.sql b/packages/schemas/tables/applications.sql
index 3c8cfd313..6645179f6 100644
--- a/packages/schemas/tables/applications.sql
+++ b/packages/schemas/tables/applications.sql
@@ -7,6 +7,7 @@ create table applications (
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
name varchar(256) not null,
+ /** @deprecated The internal client secret. Note it is only used for internal validation, and the actual secret should be stored in the `application_secrets` table. You should NOT use it unless you are sure what you are doing. */
secret varchar(64) not null,
description text,
type application_type not null,