0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -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 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 ? (
<>

View file

@ -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;

View file

@ -1,9 +1,4 @@
{
"tags": [
{
"name": "Dev feature"
}
],
"paths": {
"/api/applications/{id}/legacy-secret": {
"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."
}
],
"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."

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({
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,

View file

@ -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);

View file

@ -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"
}
}
}
}
}
}
}
}
},

View file

@ -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.');
}

View file

@ -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);
};

View file

@ -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);

View file

@ -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,