0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

chore: add SAML to application type

This commit is contained in:
Darcy Ye 2024-11-04 17:22:20 +08:00
parent 80ae0bb6c7
commit 7a949b3848
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
16 changed files with 163 additions and 45 deletions

View file

@ -161,7 +161,7 @@ function CreateForm({
>
{Object.values(ApplicationType)
// Other application types (e.g. "Protected") should not show up in the creation modal
.filter((value) =>
.filter((value): value is Exclude<ApplicationType, ApplicationType.SAML> =>
[
ApplicationType.Native,
ApplicationType.SPA,

View file

@ -1,5 +1,4 @@
import type { ApplicationType } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import { ApplicationType, Theme } from '@logto/schemas';
import {
darkModeApplicationIconMap,
@ -16,7 +15,8 @@ type Props = {
};
const getIcon = (type: ApplicationType, isLightMode: boolean, isThirdParty?: boolean) => {
if (isThirdParty) {
// We have ensured that SAML applications are always third party in DB schema, we use `??` here to make TypeScript happy.
if (isThirdParty ?? type === ApplicationType.SAML) {
return isLightMode ? thirdPartyApplicationIcon : thirdPartyApplicationIconDark;
}

View file

@ -1,3 +1,4 @@
import { ApplicationType } from '@logto/schemas';
import { useCallback, useMemo } from 'react';
import { guides } from '@/assets/docs/guides';
@ -98,7 +99,8 @@ export const useAppGuideMetadata = (): {
return accumulated;
}
if (isThirdParty) {
// We have ensured that SAML applications are always third party in DB schema, we use `||` here to make TypeScript happy.
if (target === ApplicationType.SAML || isThirdParty) {
return {
...accumulated,
[thirdPartyAppCategory]: [...accumulated[thirdPartyAppCategory], guide],

View file

@ -1,4 +1,4 @@
import { type Application } from '@logto/schemas';
import { type Application, ApplicationType } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import ApplicationIcon from '@/components/ApplicationIcon';
@ -21,7 +21,8 @@ function ApplicationPreview({ data: { id, name, isThirdParty, type } }: Props) {
<ItemPreview
title={name}
subtitle={
isThirdParty
// We have ensured that SAML applications are always third party in DB schema, we use `||` here to make TypeScript happy.
isThirdParty || type === ApplicationType.SAML
? t(`${applicationTypeI18nKey.thirdParty}.title`)
: t(`${applicationTypeI18nKey[type]}.title`)
}

View file

@ -12,7 +12,8 @@ import TraditionalWebAppDark from '@/assets/icons/traditional-web-app-dark.svg?r
import TraditionalWebApp from '@/assets/icons/traditional-web-app.svg?react';
type ApplicationIconMap = {
[key in ApplicationType]: SvgComponent;
// TODO: Add SAML icon when we support SAML application in console
[key in Exclude<ApplicationType, ApplicationType.SAML>]: SvgComponent;
};
export const lightModeApplicationIconMap: ApplicationIconMap = Object.freeze({

View file

@ -1,4 +1,4 @@
import { type ApplicationResponse } from '@logto/schemas';
import { ApplicationType, type ApplicationResponse } from '@logto/schemas';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -26,24 +26,30 @@ function GuideDrawer({ app, secrets, onClose }: Props) {
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
const appType = useMemo(
// SAML application is actually a Traditional application, the same as OIDC applications.
() => (app.type === ApplicationType.SAML ? ApplicationType.Traditional : app.type),
[app.type]
);
const structuredMetadata = useMemo(
() => getStructuredAppGuideMetadata({ categories: [app.type] }),
[getStructuredAppGuideMetadata, app.type]
() => getStructuredAppGuideMetadata({ categories: [appType] }),
[getStructuredAppGuideMetadata, appType]
);
const hasSingleGuide = useMemo(() => {
return structuredMetadata[app.type].length === 1;
}, [app.type, structuredMetadata]);
return structuredMetadata[appType].length === 1;
}, [appType, structuredMetadata]);
useEffect(() => {
if (hasSingleGuide) {
const guide = structuredMetadata[app.type][0];
const guide = structuredMetadata[appType][0];
if (guide) {
const { id, metadata } = guide;
setSelectedGuide({ id, metadata });
}
}
}, [hasSingleGuide, app.type, structuredMetadata]);
}, [hasSingleGuide, appType, structuredMetadata]);
return (
<div className={styles.drawerContainer}>
@ -75,8 +81,8 @@ function GuideDrawer({ app, secrets, onClose }: Props) {
{!selectedGuide && (
<GuideCardGroup
className={styles.cardGroup}
categoryName={t(`categories.${app.type}`)}
guides={structuredMetadata[app.type]}
categoryName={t(`categories.${appType}`)}
guides={structuredMetadata[appType]}
onClickGuide={(guide) => {
setSelectedGuide(guide);
}}

View file

@ -123,7 +123,8 @@ function ApplicationDetailsContent({ data, secrets, oidcConfig, onApplicationUpd
icon={<ApplicationIcon type={data.type} isThirdParty={data.isThirdParty} />}
title={data.name}
primaryTag={
data.isThirdParty
// We have ensured that SAML applications are always third party in DB schema, we use `||` here to make TypeScript happy.
data.isThirdParty || data.type === ApplicationType.SAML
? t(`${applicationTypeI18nKey.thirdParty}.title`)
: t(`${applicationTypeI18nKey[data.type]}.title`)
}

View file

@ -177,7 +177,18 @@ function ConfigForm({
placeholder={t(
'enterprise_sso_details.idp_initiated_auth_config.empty_applications_placeholder'
)}
options={applications.map((application) => ({
options={applications
.filter(
// See definition of `applicationsSearchUrl`, there is only non-third party SPA/Traditional applications here, and SAML applications are always third party secured by DB schema, we need to manually exclude other application types here to make TypeScript happy.
(
application
): application is Exclude<Application, 'type'> & {
type: Extract<ApplicationType, 'SPA' | 'Traditional'>;
} =>
application.type === ApplicationType.SPA ||
application.type === ApplicationType.Traditional
)
.map((application) => ({
value: application.id,
title: (
<span>

View file

@ -1,5 +1,5 @@
// TODO: @darcyYe refactor this file later to remove disable max line comment
/* eslint-disable max-lines */
import type { Role } from '@logto/schemas';
import {
Applications,
@ -147,10 +147,14 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
response: Applications.guard,
status: [200, 400, 422, 500],
}),
// eslint-disable-next-line complexity
async (ctx, next) => {
const { oidcClientMetadata, protectedAppMetadata, ...rest } = ctx.guard.body;
if (rest.type === ApplicationType.SAML) {
throw new RequestError('application.use_saml_app_api');
}
await Promise.all([
rest.type === ApplicationType.MachineToMachine &&
quota.guardTenantUsageByKey('machineToMachineLimit'),
@ -262,6 +266,11 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
const { isAdmin, protectedAppMetadata, ...rest } = body;
const pendingUpdateApplication = await queries.applications.findApplicationById(id);
if (pendingUpdateApplication.type === ApplicationType.SAML) {
throw new RequestError('application.use_saml_app_api');
}
// @deprecated
// User can enable the admin access of Machine-to-Machine apps by switching on a toggle on Admin Console.
// Since those apps sit in the user tenant, we provide an internal role to apply the necessary scopes.
@ -292,8 +301,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
}
if (protectedAppMetadata) {
const { type, protectedAppMetadata: originProtectedAppMetadata } =
await queries.applications.findApplicationById(id);
const { type, protectedAppMetadata: originProtectedAppMetadata } = pendingUpdateApplication;
assertThat(type === ApplicationType.Protected, 'application.protected_application_only');
assertThat(
originProtectedAppMetadata,
@ -319,9 +327,10 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
}
}
ctx.body = await (Object.keys(rest).length > 0
? queries.applications.updateApplicationById(id, rest, 'replace')
: queries.applications.findApplicationById(id));
ctx.body =
Object.keys(rest).length > 0
? await queries.applications.updateApplicationById(id, rest, 'replace')
: pendingUpdateApplication;
return next();
}
@ -359,3 +368,4 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
applicationCustomDataRoutes(router, tenant);
}
/* eslint-enable max-lines */

View file

@ -27,7 +27,8 @@ describe('application secrets', () => {
await Promise.all(applications.map(async ({ id }) => deleteApplication(id).catch(noop)));
});
it.each(Object.values(ApplicationType))(
// Exclude SAML app since it has different API for operations.
it.each(Object.values(ApplicationType).filter((type) => type !== ApplicationType.SAML))(
'should or not to create application secret for %s applications per type',
async (type) => {
const application = await createApplication(

View file

@ -26,7 +26,7 @@ describe('application APIs', () => {
expect(fetchedApplication.id).toBe(application.id);
});
it('should throw error when creating a third party application with invalid type', async () => {
it('should throw error when creating an OIDC third party application with invalid type', async () => {
await expectRejects(
createApplication('test-create-app', ApplicationType.Native, {
isThirdParty: true,
@ -35,7 +35,16 @@ describe('application APIs', () => {
);
});
it('should create third party application successfully', async () => {
it('should throw error when creating a non-third party SAML application', async () => {
await expectRejects(createApplication('test-create-saml-app', ApplicationType.SAML), {
code: 'application.use_saml_app_api',
status: 400,
});
});
// TODO: add tests for blocking updating SAML application with `PATCH /applications/:id` API, we can not do it before we implement the `POST /saml-applications` API
it('should create OIDC third party application successfully', async () => {
const applicationName = 'test-third-party-app';
const application = await createApplication(applicationName, ApplicationType.Traditional, {

View file

@ -64,8 +64,10 @@ export type ApplicationMetadata = {
description: string;
};
export const applicationTypesMetadata = Object.entries(ApplicationType).map(([key, value]) => ({
export const applicationTypesMetadata = Object.entries(ApplicationType)
.filter(([_, value]) => value !== ApplicationType.SAML)
.map(([key, value]) => ({
type: value,
name: `${key} app`,
description: `This is a ${key} app`,
})) satisfies ApplicationMetadata[];
})) satisfies ApplicationMetadata[];

View file

@ -10,6 +10,7 @@ const application = {
protected_app_metadata_is_required: 'Protected app metadata is required.',
protected_app_not_configured:
'Protected app provider is not configured. This feature is not available for open source version.',
use_saml_app_api: 'Use `[METHOD] /saml-applications(/.*)?` API to operate SAML app.',
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
protected_application_only: 'The feature is only available for protected applications.',
protected_application_misconfigured: 'Protected application is misconfigured.',

View file

@ -0,0 +1,50 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter type application_type add value 'SAML';
`);
},
down: async (pool) => {
await pool.query(sql`
alter table organization_application_relations drop constraint application_type;
alter table application_secrets drop constraint application_type;
alter table sso_connector_idp_initiated_auth_configs drop constraint application_type;
drop function check_application_type;
create type application_type_new as enum ('Native', 'SPA', 'Traditional', 'MachineToMachine', 'Protected');
delete from applications where "type"='SAML';
alter table applications
alter column "type" type application_type_new
using ("type"::text::application_type_new);
drop type application_type;
alter type application_type_new rename to application_type;
create function check_application_type(
application_id varchar(21),
variadic target_type application_type[]
) returns boolean as
$$ begin
return (select type from applications where id = application_id) = any(target_type);
end; $$ language plpgsql set search_path = public;
alter table organization_application_relations
add constraint application_type
check (check_application_type(application_id, 'MachineToMachine'));
alter table application_secrets
add constraint application_type
check (check_application_type(application_id, 'MachineToMachine', 'Traditional', 'Protected'));
alter table sso_connector_idp_initiated_auth_configs
add constraint application_type
check (check_application_type(default_application_id, 'Traditional', 'SPA'));
`);
},
};
export default alteration;

View file

@ -0,0 +1,20 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table applications
add constraint check_saml_app_third_party_consistency
check (type != 'SAML' OR (type = 'SAML' AND is_third_party = true));
`);
},
down: async (pool) => {
await pool.query(sql`
alter table applications drop constraint check_saml_app_third_party_consistency;
`);
},
};
export default alteration;

View file

@ -1,6 +1,6 @@
/* init_order = 1 */
create type application_type as enum ('Native', 'SPA', 'Traditional', 'MachineToMachine', 'Protected');
create type application_type as enum ('Native', 'SPA', 'Traditional', 'MachineToMachine', 'Protected', 'SAML');
create table applications (
tenant_id varchar(21) not null
@ -17,7 +17,10 @@ create table applications (
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
is_third_party boolean not null default false,
created_at timestamptz not null default(now()),
primary key (id)
primary key (id),
constraint check_saml_app_third_party_consistency check (
type != 'SAML' OR (type = 'SAML' AND is_third_party = true)
)
);
create index applications__id