From 3e39e2e42899701cbcfd45e8e64b93c1632784a9 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 25 Dec 2023 15:32:03 +0800 Subject: [PATCH] feat(core): route for add protected application (#5114) --- .../routes/applications/application.test.ts | 15 ++++++++++ .../src/routes/applications/application.ts | 24 +++++++++++++-- .../core/src/routes/applications/constants.ts | 10 +++++++ .../core/src/routes/applications/types.ts | 30 ++++++++++++++++--- .../core/src/routes/applications/utils.ts | 10 +++++++ .../tests/api/application/application.test.ts | 27 +++++++++++++++++ .../tests/console/applications/index.test.ts | 8 +++++ .../src/locales/de/errors/application.ts | 2 ++ .../src/locales/en/errors/application.ts | 1 + .../src/locales/es/errors/application.ts | 2 ++ .../src/locales/fr/errors/application.ts | 2 ++ .../src/locales/it/errors/application.ts | 2 ++ .../src/locales/ja/errors/application.ts | 2 ++ .../src/locales/ko/errors/application.ts | 1 + .../src/locales/pl-pl/errors/application.ts | 2 ++ .../src/locales/pt-br/errors/application.ts | 2 ++ .../src/locales/pt-pt/errors/application.ts | 2 ++ .../src/locales/ru/errors/application.ts | 2 ++ .../src/locales/tr-tr/errors/application.ts | 2 ++ .../src/locales/zh-cn/errors/application.ts | 2 ++ .../src/locales/zh-hk/errors/application.ts | 2 ++ .../src/locales/zh-tw/errors/application.ts | 2 ++ 22 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/routes/applications/constants.ts create mode 100644 packages/core/src/routes/applications/utils.ts diff --git a/packages/core/src/routes/applications/application.test.ts b/packages/core/src/routes/applications/application.test.ts index 8a10e7e57..ae1b72414 100644 --- a/packages/core/src/routes/applications/application.test.ts +++ b/packages/core/src/routes/applications/application.test.ts @@ -130,6 +130,21 @@ describe('application route', () => { }, }) ).resolves.toHaveProperty('status', 400); + await expect( + applicationRequest.post('/applications').send({ + name, + type: ApplicationType.Protected, + }) + ).resolves.toHaveProperty('status', 400); + await expect( + applicationRequest.post('/applications').send({ + name, + type: ApplicationType.Protected, + protectedAppMetadata: { + host: 'https://example.com', + }, + }) + ).resolves.toHaveProperty('status', 400); }); it('GET /applications/:id', async () => { diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 0fcf9daf6..9c6ced7d7 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -4,7 +4,6 @@ import { buildDemoAppDataForTenant, InternalRole, ApplicationType, - applicationPatchGuard, } from '@logto/schemas'; import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; @@ -20,7 +19,12 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from '../types.js'; -import { applicationResponseGuard, applicationCreateGuard } from './types.js'; +import { + applicationResponseGuard, + applicationCreateGuard, + applicationPatchGuard, +} from './types.js'; +import { buildProtectedAppMetadata } from './utils.js'; const includesInternalAdminRole = (roles: Readonly>) => roles.some(({ role: { name } }) => name === InternalRole.Admin); @@ -127,7 +131,7 @@ export default function applicationRoutes( status: [200, 422], }), async (ctx, next) => { - const { oidcClientMetadata, ...rest } = ctx.guard.body; + const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body; // When creating a m2m app, should check both m2m limit and application limit. if (rest.type === ApplicationType.MachineToMachine) { @@ -135,6 +139,11 @@ export default function applicationRoutes( } await quota.guardKey('applicationsLimit'); + assertThat( + rest.type !== ApplicationType.Protected || protectedAppMetadata, + 'application.protected_app_metadata_is_required' + ); + // Third party applications must be traditional type if (rest.isThirdParty) { assertThat( @@ -143,10 +152,19 @@ export default function applicationRoutes( ); } + // TODO LOG-7794: check and add domain to Cloudflare + + // TODO LOG-7520: sync configs to Cloudflare KV + ctx.body = await insertApplication({ id: generateStandardId(), secret: generateStandardSecret(), oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata), + ...conditional( + rest.type === ApplicationType.Protected && + protectedAppMetadata && + buildProtectedAppMetadata(protectedAppMetadata) + ), ...rest, }); diff --git a/packages/core/src/routes/applications/constants.ts b/packages/core/src/routes/applications/constants.ts new file mode 100644 index 000000000..071638e3b --- /dev/null +++ b/packages/core/src/routes/applications/constants.ts @@ -0,0 +1,10 @@ +import { type ProtectedAppMetadata } from '@logto/schemas'; + +export const defaultProtectedAppSessionDuration: ProtectedAppMetadata['sessionDuration'] = + 60 * 60 * 24 * 14; // 14 days + +export const defaultProtectedAppPageRules: ProtectedAppMetadata['pageRules'] = [ + { + path: '^/', // Match all paths + }, +]; diff --git a/packages/core/src/routes/applications/types.ts b/packages/core/src/routes/applications/types.ts index 516f22859..aacb98d08 100644 --- a/packages/core/src/routes/applications/types.ts +++ b/packages/core/src/routes/applications/types.ts @@ -1,6 +1,7 @@ import { Applications, applicationCreateGuard as originalApplicationCreateGuard, + applicationPatchGuard as originalApplicationPatchGuard, } from '@logto/schemas'; import { z } from 'zod'; @@ -23,10 +24,31 @@ export const applicationResponseGuard: typeof Applications.guard = EnvSet.values .omit({ isThirdParty: true, type: true, protectedAppMetadata: true }) .extend({ type: z.nativeEnum(OriginalApplicationType) }); +const applicationCreateGuardWithProtectedAppMetadata = originalApplicationCreateGuard + .omit({ + protectedAppMetadata: true, + }) + .extend({ + protectedAppMetadata: z + .object({ + host: z.string(), + origin: z.string(), + }) + .optional(), + }); + +// FIXME: @wangsijie Remove this guard once protected app is ready // @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler -export const applicationCreateGuard: typeof originalApplicationCreateGuard = EnvSet.values - .isDevFeaturesEnabled - ? originalApplicationCreateGuard - : originalApplicationCreateGuard +export const applicationCreateGuard: typeof applicationCreateGuardWithProtectedAppMetadata = EnvSet + .values.isDevFeaturesEnabled + ? applicationCreateGuardWithProtectedAppMetadata + : applicationCreateGuardWithProtectedAppMetadata .omit({ isThirdParty: true, type: true, protectedAppMetadata: true }) .extend({ type: z.nativeEnum(OriginalApplicationType) }); + +// FIXME: @wangsijie Remove this guard once protected app is ready +// @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler +export const applicationPatchGuard: typeof originalApplicationPatchGuard = EnvSet.values + .isDevFeaturesEnabled + ? originalApplicationPatchGuard + : originalApplicationPatchGuard.omit({ protectedAppMetadata: true }); diff --git a/packages/core/src/routes/applications/utils.ts b/packages/core/src/routes/applications/utils.ts new file mode 100644 index 000000000..4ea9b094b --- /dev/null +++ b/packages/core/src/routes/applications/utils.ts @@ -0,0 +1,10 @@ +import { defaultProtectedAppPageRules, defaultProtectedAppSessionDuration } from './constants.js'; + +export const buildProtectedAppMetadata = ({ host, origin }: { host: string; origin: string }) => ({ + protectedAppMetadata: { + host, + origin, + sessionDuration: defaultProtectedAppSessionDuration, + pageRules: defaultProtectedAppPageRules, + }, +}); diff --git a/packages/integration-tests/src/tests/api/application/application.test.ts b/packages/integration-tests/src/tests/api/application/application.test.ts index aa2d97cf4..e4d1c6d3e 100644 --- a/packages/integration-tests/src/tests/api/application/application.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.test.ts @@ -48,6 +48,33 @@ describe('admin console application', () => { await deleteApplication(application.id); }); + it('should create protected application successfully', async () => { + const applicationName = 'test-protected-app'; + const metadata = { + origin: 'https://example.com', + host: 'example.protected.app', + }; + + const application = await createApplication(applicationName, ApplicationType.Protected, { + // @ts-expect-error the create guard has been modified + protectedAppMetadata: metadata, + }); + + expect(application.name).toBe(applicationName); + expect(application.type).toBe(ApplicationType.Protected); + expect(application.protectedAppMetadata).toHaveProperty('origin', metadata.origin); + expect(application.protectedAppMetadata).toHaveProperty('host', metadata.host); + expect(application.protectedAppMetadata).toHaveProperty('sessionDuration'); + await deleteApplication(application.id); + }); + + it('should throw error when creating a protected application with invalid type', async () => { + await expectRejects(createApplication('test-create-app', ApplicationType.Protected), { + code: 'application.protected_app_metadata_is_required', + statusCode: 400, + }); + }); + it('should update application details successfully', async () => { const application = await createApplication('test-update-app', ApplicationType.Traditional); diff --git a/packages/integration-tests/src/tests/console/applications/index.test.ts b/packages/integration-tests/src/tests/console/applications/index.test.ts index 46fb5a679..1a27990bb 100644 --- a/packages/integration-tests/src/tests/console/applications/index.test.ts +++ b/packages/integration-tests/src/tests/console/applications/index.test.ts @@ -1,3 +1,5 @@ +import { ApplicationType } from '@logto/schemas'; + import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js'; import { expectConfirmModalAndAct, @@ -222,6 +224,12 @@ describe('applications', () => { it.each(applicationTypesMetadata)( 'can create and modify a(n) $type application without framework', async (app: ApplicationMetadata) => { + if (app.type === ApplicationType.Protected) { + // TODO @wangsijie: Remove this guard once protected app is ready + expect(true).toBe(true); + return; + } + await expect(page).toClick('div[class$=main] div[class$=headline] button span', { text: 'Create application', }); diff --git a/packages/phrases/src/locales/de/errors/application.ts b/packages/phrases/src/locales/de/errors/application.ts index 846dcbc30..12dd314b8 100644 --- a/packages/phrases/src/locales/de/errors/application.ts +++ b/packages/phrases/src/locales/de/errors/application.ts @@ -7,6 +7,8 @@ const application = { 'Nur traditionelle Webanwendungen können als Drittanbieter-App markiert werden.', third_party_application_only: 'Das Feature ist nur für Drittanbieter-Anwendungen verfügbar.', user_consent_scopes_not_found: 'Ungültige Benutzerzustimmungsbereiche.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 2441b17fe..4ddf85a8d 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -6,6 +6,7 @@ const application = { 'Only traditional web applications can be marked as a third-party app.', third_party_application_only: 'The feature is only available for third-party applications.', user_consent_scopes_not_found: 'Invalid user consent scopes.', + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/es/errors/application.ts b/packages/phrases/src/locales/es/errors/application.ts index 86af3b6fe..01f09a7b1 100644 --- a/packages/phrases/src/locales/es/errors/application.ts +++ b/packages/phrases/src/locales/es/errors/application.ts @@ -7,6 +7,8 @@ const application = { 'Solo las aplicaciones web tradicionales pueden ser marcadas como una aplicación de terceros.', third_party_application_only: 'La función solo está disponible para aplicaciones de terceros.', user_consent_scopes_not_found: 'Ámbitos de consentimiento de usuario no válidos.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/fr/errors/application.ts b/packages/phrases/src/locales/fr/errors/application.ts index bf8573928..310f2d946 100644 --- a/packages/phrases/src/locales/fr/errors/application.ts +++ b/packages/phrases/src/locales/fr/errors/application.ts @@ -8,6 +8,8 @@ const application = { third_party_application_only: 'La fonctionnalité est uniquement disponible pour les applications tierces.', user_consent_scopes_not_found: 'Portées de consentement utilisateur invalides.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/it/errors/application.ts b/packages/phrases/src/locales/it/errors/application.ts index 59d734f9f..54645fca3 100644 --- a/packages/phrases/src/locales/it/errors/application.ts +++ b/packages/phrases/src/locales/it/errors/application.ts @@ -8,6 +8,8 @@ const application = { third_party_application_only: 'La funzionalità è disponibile solo per le applicazioni di terze parti.', user_consent_scopes_not_found: 'Scopi di consenso utente non validi.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ja/errors/application.ts b/packages/phrases/src/locales/ja/errors/application.ts index a7f2041c3..1542c3ca7 100644 --- a/packages/phrases/src/locales/ja/errors/application.ts +++ b/packages/phrases/src/locales/ja/errors/application.ts @@ -7,6 +7,8 @@ const application = { '伝統的なWebアプリケーションにのみ、サードパーティアプリとしてマークできます。', third_party_application_only: 'この機能はサードパーティアプリケーションにのみ利用可能です。', user_consent_scopes_not_found: '無効なユーザー同意スコープ。', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ko/errors/application.ts b/packages/phrases/src/locales/ko/errors/application.ts index f36edbc35..0a312d1ae 100644 --- a/packages/phrases/src/locales/ko/errors/application.ts +++ b/packages/phrases/src/locales/ko/errors/application.ts @@ -6,6 +6,7 @@ const application = { 'Only traditional web applications can be marked as a third-party app.', third_party_application_only: 'The feature is only available for third-party applications.', user_consent_scopes_not_found: '유효하지 않은 사용자 동의 범위입니다.', + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pl-pl/errors/application.ts b/packages/phrases/src/locales/pl-pl/errors/application.ts index 7a99df358..0d93c2916 100644 --- a/packages/phrases/src/locales/pl-pl/errors/application.ts +++ b/packages/phrases/src/locales/pl-pl/errors/application.ts @@ -6,6 +6,8 @@ const application = { 'Tylko tradycyjne aplikacje internetowe mogą być oznaczone jako aplikacja zewnętrzna.', third_party_application_only: 'Ta funkcja jest dostępna tylko dla aplikacji zewnętrznych.', user_consent_scopes_not_found: 'Nieprawidłowe zakresy zgody użytkownika.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pt-br/errors/application.ts b/packages/phrases/src/locales/pt-br/errors/application.ts index cb3fe5753..f96ff9f35 100644 --- a/packages/phrases/src/locales/pt-br/errors/application.ts +++ b/packages/phrases/src/locales/pt-br/errors/application.ts @@ -7,6 +7,8 @@ const application = { 'Apenas aplicativos da web tradicionais podem ser marcados como um aplicativo de terceiros.', third_party_application_only: 'O recurso está disponível apenas para aplicativos de terceiros.', user_consent_scopes_not_found: 'Escopos de consentimento do usuário inválidos.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pt-pt/errors/application.ts b/packages/phrases/src/locales/pt-pt/errors/application.ts index e60c05dfd..cbc62b7c5 100644 --- a/packages/phrases/src/locales/pt-pt/errors/application.ts +++ b/packages/phrases/src/locales/pt-pt/errors/application.ts @@ -7,6 +7,8 @@ const application = { 'Apenas aplicações web tradicionais podem ser marcadas como uma aplicação de terceiros.', third_party_application_only: 'A funcionalidade só está disponível para aplicações de terceiros.', user_consent_scopes_not_found: 'Escopos de consentimento de utilizador inválidos.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ru/errors/application.ts b/packages/phrases/src/locales/ru/errors/application.ts index 1393ef8da..c3e10163f 100644 --- a/packages/phrases/src/locales/ru/errors/application.ts +++ b/packages/phrases/src/locales/ru/errors/application.ts @@ -8,6 +8,8 @@ const application = { third_party_application_only: 'Эта функция доступна только для приложений сторонних разработчиков.', user_consent_scopes_not_found: 'Недействительные области согласия пользователя.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/tr-tr/errors/application.ts b/packages/phrases/src/locales/tr-tr/errors/application.ts index baf2ab0f1..323c434b5 100644 --- a/packages/phrases/src/locales/tr-tr/errors/application.ts +++ b/packages/phrases/src/locales/tr-tr/errors/application.ts @@ -6,6 +6,8 @@ const application = { 'Sadece geleneksel web uygulamaları üçüncü taraf uygulaması olarak işaretlenebilir.', third_party_application_only: 'Bu özellik sadece üçüncü taraf uygulamalar için geçerlidir.', user_consent_scopes_not_found: 'Geçersiz kullanıcı onay kapsamları.', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-cn/errors/application.ts b/packages/phrases/src/locales/zh-cn/errors/application.ts index 929a22583..f4086e00e 100644 --- a/packages/phrases/src/locales/zh-cn/errors/application.ts +++ b/packages/phrases/src/locales/zh-cn/errors/application.ts @@ -5,6 +5,8 @@ const application = { invalid_third_party_application_type: '只有传统网络应用程序可以标记为第三方应用。', third_party_application_only: '该功能仅适用于第三方应用程序。', user_consent_scopes_not_found: '无效的用户同意范围。', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-hk/errors/application.ts b/packages/phrases/src/locales/zh-hk/errors/application.ts index f21669815..1ea5b0af0 100644 --- a/packages/phrases/src/locales/zh-hk/errors/application.ts +++ b/packages/phrases/src/locales/zh-hk/errors/application.ts @@ -5,6 +5,8 @@ const application = { invalid_third_party_application_type: '只有傳統網頁應用程式才能被標記為第三方應用程式。', third_party_application_only: '此功能只適用於第三方應用程式。', user_consent_scopes_not_found: '無效的使用者同意範圍。', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-tw/errors/application.ts b/packages/phrases/src/locales/zh-tw/errors/application.ts index 30946017e..d2a6adcbc 100644 --- a/packages/phrases/src/locales/zh-tw/errors/application.ts +++ b/packages/phrases/src/locales/zh-tw/errors/application.ts @@ -5,6 +5,8 @@ const application = { invalid_third_party_application_type: '僅傳統網路應用程式可以標記為第三方應用程式。', third_party_application_only: '該功能僅適用於第三方應用程式。', user_consent_scopes_not_found: '無效的使用者同意範圍。', + /** UNTRANSLATED */ + protected_app_metadata_is_required: 'Protected app metadata is required.', }; export default Object.freeze(application);