0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

Merge pull request #6322 from logto-io/gao-launch-multi-app-secrets

chore: launch multiple app secrets
This commit is contained in:
Gao Sun 2024-07-29 15:26:42 +08:00 committed by GitHub
commit a46d5ac6a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 135 additions and 37 deletions

View 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.

View file

@ -17,7 +17,6 @@ import CirclePlus from '@/assets/icons/circle-plus.svg?react';
import Plus from '@/assets/icons/plus.svg?react'; import Plus from '@/assets/icons/plus.svg?react';
import ActionsButton from '@/components/ActionsButton'; import ActionsButton from '@/components/ActionsButton';
import FormCard from '@/components/FormCard'; import FormCard from '@/components/FormCard';
import { isDevFeaturesEnabled } from '@/consts/env';
import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc'; import { openIdProviderConfigPath, openIdProviderPath } from '@/consts/oidc';
import { AppDataContext } from '@/contexts/AppDataProvider'; import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
@ -239,17 +238,7 @@ function EndpointsAndCredentials({
<FormField title="application_details.application_id"> <FormField title="application_details.application_id">
<CopyToClipboard displayType="block" value={id} variant="border" /> <CopyToClipboard displayType="block" value={id} variant="border" />
</FormField> </FormField>
{!isDevFeaturesEnabled && shouldShowAppSecrets && ( {shouldShowAppSecrets && (
<FormField title="application_details.application_secret">
<CopyToClipboard
hasVisibilityToggle
displayType="block"
value={secret}
variant="border"
/>
</FormField>
)}
{isDevFeaturesEnabled && shouldShowAppSecrets && (
<FormField title="application_details.application_secret_other"> <FormField title="application_details.application_secret_other">
{secretsData.length === 0 && !secrets.error ? ( {secretsData.length === 0 && !secrets.error ? (
<> <>

View file

@ -22,7 +22,7 @@ import Provider, { errors } from 'oidc-provider';
import getRawBody from 'raw-body'; import getRawBody from 'raw-body';
import snakecaseKeys from 'snakecase-keys'; 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 { addOidcEventListeners } from '#src/event-listeners/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
@ -410,10 +410,7 @@ export default function initOidc(
return next(); return next();
}); });
if (EnvSet.values.isDevFeaturesEnabled) {
oidc.use(koaAppSecretTranspilation(queries)); oidc.use(koaAppSecretTranspilation(queries));
}
oidc.use(koaBodyEtag()); oidc.use(koaBodyEtag());
return oidc; return oidc;

View file

@ -1,9 +1,4 @@
{ {
"tags": [
{
"name": "Dev feature"
}
],
"paths": { "paths": {
"/api/applications/{id}/legacy-secret": { "/api/applications/{id}/legacy-secret": {
"delete": { "delete": {

View file

@ -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." "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": { "paths": {
"/api/applications": { "/api/applications": {
"get": { "get": {
@ -19,7 +28,21 @@
], ],
"responses": { "responses": {
"200": { "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": { "responses": {
"200": { "200": {
"description": "The application was created successfully." "description": "The application was created successfully.",
"content": {
"application/json": {
"schema": {
"properties": {
"secret": {
"$ref": "#/components/schemas/ApplicationLegacySecret"
}
}
}
}
}
}, },
"422": { "422": {
"description": "Validation error. Please check the request body." "description": "Validation error. Please check the request body."
@ -63,7 +97,18 @@
"description": "Get application details by ID.", "description": "Get application details by ID.",
"responses": { "responses": {
"200": { "200": {
"description": "Details of the application." "description": "Details of the application.",
"content": {
"application/json": {
"schema": {
"properties": {
"secret": {
"$ref": "#/components/schemas/ApplicationLegacySecret"
}
}
}
}
}
}, },
"404": { "404": {
"description": "The application with the specified ID was not found." "description": "The application with the specified ID was not found."
@ -88,7 +133,18 @@
}, },
"responses": { "responses": {
"200": { "200": {
"description": "The application was updated successfully." "description": "The application was updated successfully.",
"content": {
"application/json": {
"schema": {
"properties": {
"secret": {
"$ref": "#/components/schemas/ApplicationLegacySecret"
}
}
}
}
}
}, },
"404": { "404": {
"description": "The application with the specified ID was not found." "description": "The application with the specified ID was not found."

View file

@ -182,11 +182,9 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
); );
} }
const getSecret = () =>
EnvSet.values.isDevFeaturesEnabled ? generateInternalSecret() : generateStandardSecret();
const application = await queries.applications.insertApplication({ const application = await queries.applications.insertApplication({
id: generateStandardId(), id: generateStandardId(),
secret: getSecret(), secret: generateInternalSecret(),
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata), oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
...conditional( ...conditional(
rest.type === ApplicationType.Protected && rest.type === ApplicationType.Protected &&
@ -196,7 +194,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
...rest, ...rest,
}); });
if (EnvSet.values.isDevFeaturesEnabled && hasSecrets(application.type)) { if (hasSecrets(application.type)) {
await queries.applicationSecrets.insert({ await queries.applicationSecrets.insert({
name: 'Default secret', name: 'Default secret',
applicationId: application.id, applicationId: application.id,

View file

@ -66,9 +66,7 @@ const createRouters = (tenant: TenantContext) => {
applicationRoleRoutes(managementRouter, tenant); applicationRoleRoutes(managementRouter, tenant);
applicationProtectedAppMetadataRoutes(managementRouter, tenant); applicationProtectedAppMetadataRoutes(managementRouter, tenant);
applicationOrganizationRoutes(managementRouter, tenant); applicationOrganizationRoutes(managementRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) {
applicationSecretRoutes(managementRouter, tenant); applicationSecretRoutes(managementRouter, tenant);
}
// Third-party application related routes // Third-party application related routes
applicationUserConsentScopeRoutes(managementRouter, tenant); applicationUserConsentScopeRoutes(managementRouter, tenant);

View file

@ -12,7 +12,21 @@
"description": "Get applications associated with the organization.", "description": "Get applications associated with the organization.",
"responses": { "responses": {
"200": { "200": {
"description": "A list of applications." "description": "A list of applications.",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"secret": {
"$ref": "#/components/schemas/ApplicationLegacySecret"
}
}
}
}
}
}
} }
} }
}, },

View file

@ -27,6 +27,7 @@ import {
devFeatureTag, devFeatureTag,
findSupplementFiles, findSupplementFiles,
normalizePath, normalizePath,
pruneSwaggerDocument,
removeUnnecessaryOperations, removeUnnecessaryOperations,
shouldThrow, shouldThrow,
validateSupplement, validateSupplement,
@ -299,6 +300,8 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
baseDocument baseDocument
); );
pruneSwaggerDocument(data);
if (EnvSet.values.isUnitTest) { if (EnvSet.values.isUnitTest) {
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.'); getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
} }

View file

@ -2,7 +2,7 @@ import assert from 'node:assert';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; 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 { OpenAPIV3 } from 'openapi-types';
import { z } from 'zod'; import { z } from 'zod';
@ -264,3 +264,32 @@ export const removeUnnecessaryOperations = (
}; };
export const shouldThrow = () => !EnvSet.values.isProduction || EnvSet.values.isIntegrationTest; 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);
};

View file

@ -9,11 +9,11 @@ import {
deleteApplicationSecret, deleteApplicationSecret,
getApplicationSecrets, getApplicationSecrets,
} from '#src/api/application.js'; } from '#src/api/application.js';
import { devFeatureTest, randomString } from '#src/utils.js'; import { randomString } from '#src/utils.js';
const defaultSecretName = 'Default secret'; const defaultSecretName = 'Default secret';
devFeatureTest.describe('application secrets', () => { describe('application secrets', () => {
const applications: Application[] = []; const applications: Application[] = [];
const createApplication = async (...args: Parameters<typeof createApplicationApi>) => { const createApplication = async (...args: Parameters<typeof createApplicationApi>) => {
const created = await createApplicationApi(...args); const created = await createApplicationApi(...args);

View file

@ -7,6 +7,7 @@ create table applications (
references tenants (id) on update cascade on delete cascade, references tenants (id) on update cascade on delete cascade,
id varchar(21) not null, id varchar(21) not null,
name varchar(256) 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, secret varchar(64) not null,
description text, description text,
type application_type not null, type application_type not null,