mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
chore: launch multiple app secrets
This commit is contained in:
parent
5e7145b20c
commit
b188bb1619
12 changed files with 135 additions and 37 deletions
18
.changeset/stale-planets-sneeze.md
Normal file
18
.changeset/stale-planets-sneeze.md
Normal file
|
@ -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.
|
|
@ -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({
|
|||
<FormField title="application_details.application_id">
|
||||
<CopyToClipboard displayType="block" value={id} variant="border" />
|
||||
</FormField>
|
||||
{!isDevFeaturesEnabled && shouldShowAppSecrets && (
|
||||
<FormField title="application_details.application_secret">
|
||||
<CopyToClipboard
|
||||
hasVisibilityToggle
|
||||
displayType="block"
|
||||
value={secret}
|
||||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{isDevFeaturesEnabled && shouldShowAppSecrets && (
|
||||
{shouldShowAppSecrets && (
|
||||
<FormField title="application_details.application_secret_other">
|
||||
{secretsData.length === 0 && !secrets.error ? (
|
||||
<>
|
||||
|
|
|
@ -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(koaBodyEtag());
|
||||
|
||||
return oidc;
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Dev feature"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/applications/{id}/legacy-secret": {
|
||||
"delete": {
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -182,11 +182,9 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
|||
);
|
||||
}
|
||||
|
||||
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<T extends ManagementApiRouter>(
|
|||
...rest,
|
||||
});
|
||||
|
||||
if (EnvSet.values.isDevFeaturesEnabled && hasSecrets(application.type)) {
|
||||
if (hasSecrets(application.type)) {
|
||||
await queries.applicationSecrets.insert({
|
||||
name: 'Default secret',
|
||||
applicationId: application.id,
|
||||
|
|
|
@ -66,9 +66,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
applicationRoleRoutes(managementRouter, tenant);
|
||||
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
|
||||
applicationOrganizationRoutes(managementRouter, tenant);
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
applicationSecretRoutes(managementRouter, tenant);
|
||||
}
|
||||
|
||||
// Third-party application related routes
|
||||
applicationUserConsentScopeRoutes(managementRouter, tenant);
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
devFeatureTag,
|
||||
findSupplementFiles,
|
||||
normalizePath,
|
||||
pruneSwaggerDocument,
|
||||
removeUnnecessaryOperations,
|
||||
shouldThrow,
|
||||
validateSupplement,
|
||||
|
@ -299,6 +300,8 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
|||
baseDocument
|
||||
);
|
||||
|
||||
pruneSwaggerDocument(data);
|
||||
|
||||
if (EnvSet.values.isUnitTest) {
|
||||
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import assert from 'node:assert';
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isKeyInObject, type Optional } from '@silverhand/essentials';
|
||||
import { isKeyInObject, isObject, type Optional } from '@silverhand/essentials';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -264,3 +264,32 @@ export const removeUnnecessaryOperations = (
|
|||
};
|
||||
|
||||
export const shouldThrow = () => !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);
|
||||
};
|
||||
|
|
|
@ -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<typeof createApplicationApi>) => {
|
||||
const created = await createApplicationApi(...args);
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue