0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core): route for add protected application (#5114)

This commit is contained in:
wangsijie 2023-12-25 15:32:03 +08:00 committed by GitHub
parent ee268ac028
commit 3e39e2e428
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 145 additions and 7 deletions

View file

@ -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 () => {

View file

@ -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<Array<{ role: Role }>>) =>
roles.some(({ role: { name } }) => name === InternalRole.Admin);
@ -127,7 +131,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
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<T extends AuthedRouter>(
}
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<T extends AuthedRouter>(
);
}
// 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,
});

View file

@ -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
},
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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