0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

refactor: generate application secret on creation

This commit is contained in:
Gao Sun 2024-07-23 11:40:54 +08:00
parent e8a55b38d0
commit ef325b25e4
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 79 additions and 69 deletions

View file

@ -1,10 +1,10 @@
import { import {
type ApplicationSecret, type ApplicationSecret,
ApplicationType,
DomainStatus, DomainStatus,
type Application, type Application,
type SnakeCaseOidcConfig, type SnakeCaseOidcConfig,
internalPrefix, internalPrefix,
hasSecrets,
} from '@logto/schemas'; } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials'; import { appendPath } from '@silverhand/essentials';
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
@ -57,11 +57,7 @@ function EndpointsAndCredentials({
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false); const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`); const secrets = useSWR<ApplicationSecretRow[], RequestError>(`api/applications/${id}/secrets`);
const api = useApi(); const api = useApi();
const shouldShowAppSecrets = [ const shouldShowAppSecrets = hasSecrets(type);
ApplicationType.Traditional,
ApplicationType.MachineToMachine,
ApplicationType.Protected,
].includes(type);
const toggleShowMoreEndpoints = useCallback(() => { const toggleShowMoreEndpoints = useCallback(() => {
setShowMoreEndpoints((previous) => !previous); setShowMoreEndpoints((previous) => !previous);

View file

@ -17,6 +17,7 @@ import {
LogtoOidcConfigKey, LogtoOidcConfigKey,
DomainStatus, DomainStatus,
LogtoJwtTokenKey, LogtoJwtTokenKey,
internalPrefix,
} from '@logto/schemas'; } from '@logto/schemas';
import { protectedAppSignInCallbackUrl } from '#src/constants/index.js'; import { protectedAppSignInCallbackUrl } from '#src/constants/index.js';
@ -33,7 +34,7 @@ export * from './protected-app.js';
export const mockApplication: Application = { export const mockApplication: Application = {
tenantId: 'fake_tenant', tenantId: 'fake_tenant',
id: 'foo', id: 'foo',
secret: mockId, secret: internalPrefix + mockId,
name: 'foo', name: 'foo',
type: ApplicationType.SPA, type: ApplicationType.SPA,
description: null, description: null,

View file

@ -53,6 +53,7 @@ const tenantContext = new MockTenant(
), ),
updateApplicationById, updateApplicationById,
}, },
applicationSecrets: { insert: jest.fn() },
}, },
undefined, undefined,
{ {

View file

@ -5,6 +5,7 @@ import {
InternalRole, InternalRole,
ApplicationType, ApplicationType,
Applications, Applications,
hasSecrets,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
@ -40,28 +41,12 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
...[ ...[
router, router,
{ {
queries: { applications, applicationsRoles, roles }, queries,
id: tenantId, id: tenantId,
libraries: { quota, protectedApps }, libraries: { quota, protectedApps },
}, },
]: RouterInitArgs<T> ]: RouterInitArgs<T>
) { ) {
const {
deleteApplicationById,
findApplicationById,
insertApplication,
updateApplicationById,
countApplications,
findApplications,
} = applications;
const {
findApplicationsRolesByApplicationId,
insertApplicationsRoles,
deleteApplicationRole,
findApplicationsRolesByRoleId,
} = applicationsRoles;
router.get( router.get(
'/applications', '/applications',
koaPagination({ isOptional: true }), koaPagination({ isOptional: true }),
@ -107,7 +92,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
const search = parseSearchParamsForSearch(searchParams); const search = parseSearchParamsForSearch(searchParams);
const excludeApplicationsRoles = excludeRoleId const excludeApplicationsRoles = excludeRoleId
? await findApplicationsRolesByRoleId(excludeRoleId) ? await queries.applicationsRoles.findApplicationsRolesByRoleId(excludeRoleId)
: []; : [];
const excludeApplicationIds = excludeApplicationsRoles.map( const excludeApplicationIds = excludeApplicationsRoles.map(
@ -115,7 +100,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
); );
if (paginationDisabled) { if (paginationDisabled) {
ctx.body = await findApplications({ ctx.body = await queries.applications.findApplications({
search, search,
excludeApplicationIds, excludeApplicationIds,
excludeOrganizationId, excludeOrganizationId,
@ -127,14 +112,14 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
} }
const [{ count }, applications] = await Promise.all([ const [{ count }, applications] = await Promise.all([
countApplications({ queries.applications.countApplications({
search, search,
excludeApplicationIds, excludeApplicationIds,
excludeOrganizationId, excludeOrganizationId,
isThirdParty, isThirdParty,
types, types,
}), }),
findApplications( queries.applications.findApplications(
{ {
search, search,
excludeApplicationIds, excludeApplicationIds,
@ -164,24 +149,17 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
async (ctx, next) => { async (ctx, next) => {
const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body; const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body;
// When creating a m2m app, should check both m2m limit and application limit. await Promise.all([
if (rest.type === ApplicationType.MachineToMachine) { rest.type === ApplicationType.MachineToMachine && quota.guardKey('machineToMachineLimit'),
await quota.guardKey('machineToMachineLimit'); rest.isThirdParty && quota.guardKey('thirdPartyApplicationsLimit'),
} quota.guardKey('applicationsLimit'),
]);
// Guard third party application limit
if (rest.isThirdParty) {
await quota.guardKey('thirdPartyApplicationsLimit');
}
await quota.guardKey('applicationsLimit');
assertThat( assertThat(
rest.type !== ApplicationType.Protected || protectedAppMetadata, rest.type !== ApplicationType.Protected || protectedAppMetadata,
'application.protected_app_metadata_is_required' 'application.protected_app_metadata_is_required'
); );
// Third party applications must be traditional type
if (rest.isThirdParty) { if (rest.isThirdParty) {
assertThat( assertThat(
rest.type === ApplicationType.Traditional, rest.type === ApplicationType.Traditional,
@ -189,11 +167,11 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
); );
} }
const application = await insertApplication({ const getSecret = () =>
EnvSet.values.isDevFeaturesEnabled ? generateInternalSecret() : generateStandardSecret();
const application = await queries.applications.insertApplication({
id: generateStandardId(), id: generateStandardId(),
secret: EnvSet.values.isDevFeaturesEnabled secret: getSecret(),
? generateStandardSecret()
: generateInternalSecret(),
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata), oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
...conditional( ...conditional(
rest.type === ApplicationType.Protected && rest.type === ApplicationType.Protected &&
@ -203,18 +181,25 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
...rest, ...rest,
}); });
if (EnvSet.values.isDevFeaturesEnabled && hasSecrets(application.type)) {
await queries.applicationSecrets.insert({
name: 'Default secret',
applicationId: application.id,
value: generateStandardSecret(),
});
}
if (application.type === ApplicationType.Protected) { if (application.type === ApplicationType.Protected) {
try { try {
await protectedApps.syncAppConfigsToRemote(application.id); await protectedApps.syncAppConfigsToRemote(application.id);
} catch (error: unknown) { } catch (error: unknown) {
// Delete the application if failed to sync to remote // Delete the application if failed to sync to remote
await deleteApplicationById(application.id); await queries.applications.deleteApplicationById(application.id);
throw error; throw error;
} }
} }
ctx.body = application; ctx.body = application;
return next(); return next();
} }
); );
@ -238,8 +223,9 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
return next(); return next();
} }
const application = await findApplicationById(id); const application = await queries.applications.findApplicationById(id);
const applicationsRoles = await findApplicationsRolesByApplicationId(id); const applicationsRoles =
await queries.applicationsRoles.findApplicationsRolesByApplicationId(id);
ctx.body = { ctx.body = {
...application, ...application,
@ -276,8 +262,8 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
// This role is NOT intended for user assignment. // This role is NOT intended for user assignment.
if (isAdmin !== undefined) { if (isAdmin !== undefined) {
const [applicationsRoles, internalAdminRole] = await Promise.all([ const [applicationsRoles, internalAdminRole] = await Promise.all([
findApplicationsRolesByApplicationId(id), queries.applicationsRoles.findApplicationsRolesByApplicationId(id),
roles.findRoleByRoleName(InternalRole.Admin), queries.roles.findRoleByRoleName(InternalRole.Admin),
]); ]);
const usedToBeAdmin = includesInternalAdminRole(applicationsRoles); const usedToBeAdmin = includesInternalAdminRole(applicationsRoles);
@ -291,17 +277,17 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
); );
if (isAdmin && !usedToBeAdmin) { if (isAdmin && !usedToBeAdmin) {
await insertApplicationsRoles([ await queries.applicationsRoles.insertApplicationsRoles([
{ id: generateStandardId(), applicationId: id, roleId: internalAdminRole.id }, { id: generateStandardId(), applicationId: id, roleId: internalAdminRole.id },
]); ]);
} else if (!isAdmin && usedToBeAdmin) { } else if (!isAdmin && usedToBeAdmin) {
await deleteApplicationRole(id, internalAdminRole.id); await queries.applicationsRoles.deleteApplicationRole(id, internalAdminRole.id);
} }
} }
if (protectedAppMetadata) { if (protectedAppMetadata) {
const { type, protectedAppMetadata: originProtectedAppMetadata } = const { type, protectedAppMetadata: originProtectedAppMetadata } =
await findApplicationById(id); await queries.applications.findApplicationById(id);
assertThat(type === ApplicationType.Protected, 'application.protected_application_only'); assertThat(type === ApplicationType.Protected, 'application.protected_application_only');
assertThat( assertThat(
originProtectedAppMetadata, originProtectedAppMetadata,
@ -310,7 +296,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
status: 422, status: 422,
}) })
); );
await updateApplicationById(id, { await queries.applications.updateApplicationById(id, {
protectedAppMetadata: { protectedAppMetadata: {
...originProtectedAppMetadata, ...originProtectedAppMetadata,
...protectedAppMetadata, ...protectedAppMetadata,
@ -320,7 +306,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
await protectedApps.syncAppConfigsToRemote(id); await protectedApps.syncAppConfigsToRemote(id);
} catch (error: unknown) { } catch (error: unknown) {
// Revert changes on sync failure // Revert changes on sync failure
await updateApplicationById(id, { await queries.applications.updateApplicationById(id, {
protectedAppMetadata: originProtectedAppMetadata, protectedAppMetadata: originProtectedAppMetadata,
}); });
throw error; throw error;
@ -328,8 +314,8 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
} }
ctx.body = await (Object.keys(rest).length > 0 ctx.body = await (Object.keys(rest).length > 0
? updateApplicationById(id, rest) ? queries.applications.updateApplicationById(id, rest)
: findApplicationById(id)); : queries.applications.findApplicationById(id));
return next(); return next();
} }
@ -344,7 +330,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
}), }),
async (ctx, next) => { async (ctx, next) => {
const { id } = ctx.guard.params; const { id } = ctx.guard.params;
const { type, protectedAppMetadata } = await findApplicationById(id); const { type, protectedAppMetadata } = await queries.applications.findApplicationById(id);
if (type === ApplicationType.Protected && protectedAppMetadata) { if (type === ApplicationType.Protected && protectedAppMetadata) {
assertThat( assertThat(
!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0, !protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0,
@ -354,7 +340,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
await protectedApps.deleteRemoteAppConfigs(protectedAppMetadata.host); await protectedApps.deleteRemoteAppConfigs(protectedAppMetadata.host);
} }
// Note: will need delete cascade when application is joint with other tables // Note: will need delete cascade when application is joint with other tables
await deleteApplicationById(id); await queries.applications.deleteApplicationById(id);
ctx.status = 204; ctx.status = 204;
return next(); return next();

View file

@ -1,4 +1,4 @@
import { ApplicationType, type Application } from '@logto/schemas'; import { ApplicationType, hasSecrets, internalPrefix, type Application } from '@logto/schemas';
import { cond, noop } from '@silverhand/essentials'; import { cond, noop } from '@silverhand/essentials';
import { HTTPError } from 'ky'; import { HTTPError } from 'ky';
@ -11,6 +11,8 @@ import {
} from '#src/api/application.js'; } from '#src/api/application.js';
import { devFeatureTest, randomString } from '#src/utils.js'; import { devFeatureTest, randomString } from '#src/utils.js';
const defaultSecretName = 'Default secret';
devFeatureTest.describe('application secrets', () => { devFeatureTest.describe('application secrets', () => {
const applications: Application[] = []; const applications: Application[] = [];
const createApplication = async (...args: Parameters<typeof createApplicationApi>) => { const createApplication = async (...args: Parameters<typeof createApplicationApi>) => {
@ -39,19 +41,29 @@ devFeatureTest.describe('application secrets', () => {
} }
) )
); );
expect(application.secret).toMatch(new RegExp(`^${internalPrefix}`));
// Check the default secret
const secrets = await getApplicationSecrets(application.id);
if (hasSecrets(type)) {
expect(secrets).toHaveLength(1);
expect(secrets[0]).toEqual(
expect.objectContaining({
applicationId: application.id,
name: defaultSecretName,
})
);
} else {
expect(secrets).toHaveLength(0);
}
const secretName = randomString(); const secretName = randomString();
const secretPromise = createApplicationSecret({ const secretPromise = createApplicationSecret({
applicationId: application.id, applicationId: application.id,
name: secretName, name: secretName,
}); });
if ( if (hasSecrets(type)) {
[
ApplicationType.MachineToMachine,
ApplicationType.Protected,
ApplicationType.Traditional,
].includes(type)
) {
expect(await secretPromise).toEqual( expect(await secretPromise).toEqual(
expect.objectContaining({ applicationId: application.id, name: secretName }) expect.objectContaining({ applicationId: application.id, name: secretName })
); );
@ -137,8 +149,12 @@ devFeatureTest.describe('application secrets', () => {
expect(await getApplicationSecrets(application.id)).toEqual( expect(await getApplicationSecrets(application.id)).toEqual(
expect.arrayContaining([secret1, secret2]) expect.arrayContaining([secret1, secret2])
); );
await deleteApplicationSecret(application.id, secretName1);
await deleteApplicationSecret(application.id, secretName2); await Promise.all([
deleteApplicationSecret(application.id, secretName1),
deleteApplicationSecret(application.id, secretName2),
deleteApplicationSecret(application.id, defaultSecretName),
]);
expect(await getApplicationSecrets(application.id)).toEqual([]); expect(await getApplicationSecrets(application.id)).toEqual([]);
}); });
}); });

View file

@ -0,0 +1,9 @@
import { ApplicationType } from '../db-entries/custom-types.js';
/** If the application type has (or can have) secrets. */
export const hasSecrets = (type: ApplicationType) =>
[
ApplicationType.MachineToMachine,
ApplicationType.Protected,
ApplicationType.Traditional,
].includes(type);

View file

@ -1,3 +1,4 @@
export * from './application.js';
export * from './role.js'; export * from './role.js';
export * from './management-api.js'; export * from './management-api.js';
export * from './domain.js'; export * from './domain.js';