Skip to content

Commit

Permalink
Merge pull request #7001 from logto-io/yemq-enable-saml-apps-for-oss
Browse files Browse the repository at this point in the history
feat: enable SAML app for OSS version
  • Loading branch information
darcyYe authored Feb 7, 2025
2 parents 5b8b884 + 562c590 commit 8f3707a
Show file tree
Hide file tree
Showing 402 changed files with 2,879 additions and 5,113 deletions.
16 changes: 16 additions & 0 deletions .changeset/silent-moons-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@logto/console": minor
"@logto/phrases": minor
"@logto/core": minor
---

add support on SAML applications

Logto now supports acting as a SAML identity provider (IdP), enabling enterprise users to achieve secure Single Sign-On (SSO) through the standardized SAML protocol. Key features include:

- Full support for SAML 2.0 protocol
- Flexible attribute mapping configuration
- Metadata auto-configuration support
- Enterprise-grade encryption and signing

[View full documentation](https://docs.logto.io/integrate-logto/saml-app) for more details.
1 change: 0 additions & 1 deletion packages/console/src/assets/docs/guides/saml-idp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const metadata = Object.freeze({
target: ApplicationType.SAML,
isThirdParty: false,
skipGuideAfterCreation: true,
isCloud: true,
} satisfies GuideMetadata);

export default metadata;
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@
color: var(--color-error);
margin-top: _.unit(2);
}

.container {
display: flex;
align-items: center;
gap: _.unit(6);
padding: _.unit(6);
background-color: var(--color-info-container);
margin: 0 _.unit(-6) _.unit(-6);
flex: 1; // Should display in full width

.description {
flex: 1;
flex-shrink: 0;
font: var(--font-body-2);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ import { useController, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useSWRConfig } from 'swr';
import useSWR, { useSWRConfig } from 'swr';

import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import { pricingLink, defaultPageSize } from '@/consts';
import { isCloud } from '@/consts/env';
import { latestProPlanId } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { LinkButton } from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import TextInput from '@/ds-components/TextInput';
import { type RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useApplicationsUsage from '@/hooks/use-applications-usage';
import useCurrentUser from '@/hooks/use-current-user';
Expand All @@ -25,10 +29,14 @@ import modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import { isPaidPlan } from '@/utils/subscription';
import { buildUrl } from '@/utils/url';

import Footer from './Footer';
import styles from './index.module.scss';

const applicationsEndpoint = 'api/applications';
const samlApplicationsLimit = 3;

type AvailableApplicationTypeForCreation = Extract<
ApplicationType,
| ApplicationType.Native
Expand Down Expand Up @@ -66,6 +74,19 @@ function CreateForm({
} = useForm<FormData>({
defaultValues: { type: defaultCreateType, isThirdParty: isDefaultCreateThirdParty },
});

const { data } = useSWR<[Application[], number], RequestError>(
!isCloud &&
defaultCreateType === ApplicationType.SAML &&
buildUrl(applicationsEndpoint, {
page: String(1),
page_size: String(defaultPageSize),
isThirdParty: 'false',
type: ApplicationType.SAML,
})
);
const [_, samlAppTotalCount] = data ?? [];

const {
currentSubscription: { planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext);
Expand Down Expand Up @@ -148,12 +169,28 @@ function CreateForm({
}
size={defaultCreateType ? 'medium' : 'large'}
footer={
<Footer
selectedType={value}
isLoading={isSubmitting}
isThirdParty={isDefaultCreateThirdParty}
onClickCreate={onSubmit}
/>
!isCloud &&
defaultCreateType === ApplicationType.SAML &&
typeof samlAppTotalCount === 'number' &&
samlAppTotalCount >= samlApplicationsLimit ? (
<div className={styles.container}>
<div className={styles.description}>{t('upsell.paywall.saml_applications_oss')}</div>
<LinkButton
size="large"
type="primary"
title="upsell.paywall.logto_pricing_button_text"
href={pricingLink}
targetBlank="noopener"
/>
</div>
) : (
<Footer
selectedType={value}
isLoading={isSubmitting}
isThirdParty={isDefaultCreateThirdParty}
onClickCreate={onSubmit}
/>
)
}
onClose={onClose}
>
Expand Down
15 changes: 4 additions & 11 deletions packages/console/src/components/Guide/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ApplicationType } from '@logto/schemas';
import { useCallback, useMemo } from 'react';

import { guides } from '@/assets/docs/guides';
Expand Down Expand Up @@ -37,16 +36,10 @@ export const useAppGuideMetadata = (): {
} => {
const appGuides = useMemo(
() =>
guides
.filter(
({ metadata: { target, isCloud, isDevFeature } }) =>
target !== 'API' && (isCloudEnv || !isCloud) && (isDevFeaturesEnabled || !isDevFeature)
/**
* Show SAML guides when it is:
* 1. Cloud env
*/
)
.filter(({ metadata: { target } }) => target !== ApplicationType.SAML || isCloudEnv),
guides.filter(
({ metadata: { target, isCloud, isDevFeature } }) =>
target !== 'API' && (isCloudEnv || !isCloud) && (isDevFeaturesEnabled || !isDevFeature)
),
[]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,6 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, onSelectGuide }
<CheckboxGroup
className={styles.checkboxGroup}
options={allAppGuideCategories
/**
* Show SAML guides when it is:
* 1. Cloud env
*/
.filter((category) => category !== 'SAML' || isCloud)
.filter((category) => isCloud || category !== 'Protected')
.map((category) => ({
title: `guide.categories.${category}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function PlanComparisonTable() {
const proPlanM2mAppPrice = t('extra_quota_price', { value: 8 });
const thirdPartyApps = t('application.third_party');
const thirdPartyAppsTip = t('third_party_tip');
const samlApps = t('application.saml_app');

// API resources
const resourceCount = t('resource.resource_count');
Expand Down Expand Up @@ -171,6 +172,7 @@ function PlanComparisonTable() {
data: [`${freePlanM2mLimit}`, `${proPlanM2mAppLimit}||${proPlanM2mAppPrice}`, contact],
},
{ name: `${thirdPartyApps}|${thirdPartyAppsTip}`, data: ['-', unlimited, contact] },
{ name: samlApps, data: ['-', '-', '✓'] },
],
},
{
Expand Down
8 changes: 2 additions & 6 deletions packages/core/src/routes/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,7 @@ const createRouters = (tenant: TenantContext) => {
systemRoutes(managementRouter, tenant);
subjectTokenRoutes(managementRouter, tenant);
accountCentersRoutes(managementRouter, tenant);
if (EnvSet.values.isCloud || EnvSet.values.isIntegrationTest) {
samlApplicationRoutes(managementRouter, tenant);
}
samlApplicationRoutes(managementRouter, tenant);

const anonymousRouter: AnonymousRouter = new Router();

Expand All @@ -117,9 +115,7 @@ const createRouters = (tenant: TenantContext) => {
wellKnownRoutes(anonymousRouter, tenant);
statusRoutes(anonymousRouter, tenant);
authnRoutes(anonymousRouter, tenant);
if (EnvSet.values.isCloud || EnvSet.values.isIntegrationTest) {
samlApplicationAnonymousRoutes(anonymousRouter, tenant);
}
samlApplicationAnonymousRoutes(anonymousRouter, tenant);

wellKnownOpenApiRoutes(anonymousRouter, {
experienceRouters: [experienceRouter, interactionRouter],
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/routes/saml-application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ import { generateInternalSecret } from '#src/routes/applications/application-sec
import type { ManagementApiRouter, RouterInitArgs } from '#src/routes/types.js';
import { getSamlAppCallbackUrl } from '#src/saml-application/SamlApplication/utils.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';

export default function samlApplicationRoutes<T extends ManagementApiRouter>(
...[router, { id: tenantId, queries, libraries }]: RouterInitArgs<T>
) {
const {
applications: { insertApplication, findApplicationById, deleteApplicationById },
applications: {
countApplications,
insertApplication,
findApplicationById,
deleteApplicationById,
},
samlApplicationConfigs: { insertSamlApplicationConfig },
samlApplicationSecrets: {
deleteSamlApplicationSecretById,
Expand All @@ -49,7 +55,29 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(

router.post(
'/saml-applications',
koaQuotaGuard({ key: 'samlApplicationsLimit', quota }),
EnvSet.values.isCloud
? koaQuotaGuard({ key: 'samlApplicationsLimit', quota })
: // OSS can create at most 3 SAML apps.
async (ctx, next) => {
const { searchParams } = ctx.URL;
// This will only parse the `search` query param, other params will be ignored. Please use query guard to validate them.
const search = parseSearchParamsForSearch(searchParams);
const { count: samlAppCount } = await countApplications({
search,
types: [ApplicationType.SAML],
});

assertThat(
samlAppCount < 3,
new RequestError({
code: 'application.saml.reach_oss_limit',
status: 403,
limit: 3,
})
);

return next();
},
koaGuard({
body: samlApplicationCreateGuard,
response: samlApplicationResponseGuard,
Expand Down
6 changes: 2 additions & 4 deletions packages/phrases/src/locales/ar/errors/account-center.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
const account_center = {
/** UNTRANSLATED */
not_enabled: 'Account center is not enabled.',
/** UNTRANSLATED */
filed_not_editable: 'Field is not editable.',
not_enabled: 'مركز الحسابات غير ممكّن.',
filed_not_editable: 'الحقل غير قابل للتعديل.',
};

export default Object.freeze(account_center);
62 changes: 22 additions & 40 deletions packages/phrases/src/locales/ar/errors/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,48 +21,30 @@ const application = {
no_legacy_secret_found: 'لا يحتوي التطبيق على سر تراثي.',
secret_name_exists: 'اسم السر موجود بالفعل.',
saml: {
/** UNTRANSLATED */
use_saml_app_api: 'Use `[METHOD] /saml-applications(/.*)?` API to operate SAML app.',
/** UNTRANSLATED */
saml_application_only: 'The API is only available for SAML applications.',
/** UNTRANSLATED */
acs_url_binding_not_supported:
'Only HTTP-POST binding is supported for receiving SAML assertions.',
/** UNTRANSLATED */
can_not_delete_active_secret: 'Can not delete the active secret.',
/** UNTRANSLATED */
no_active_secret: 'No active secret found.',
/** UNTRANSLATED */
entity_id_required: 'Entity ID is required to generate metadata.',
/** UNTRANSLATED */
name_id_format_required: 'Name ID format is required.',
/** UNTRANSLATED */
unsupported_name_id_format: 'Unsupported name ID format.',
/** UNTRANSLATED */
missing_email_address: 'User does not have an email address.',
/** UNTRANSLATED */
email_address_unverified: 'User email address is not verified.',
/** UNTRANSLATED */
invalid_certificate_pem_format: 'Invalid PEM certificate format',
/** UNTRANSLATED */
acs_url_required: 'Assertion Consumer Service URL is required.',
/** UNTRANSLATED */
private_key_required: 'Private key is required.',
/** UNTRANSLATED */
certificate_required: 'Certificate is required.',
/** UNTRANSLATED */
invalid_saml_request: 'Invalid SAML authentication request.',
/** UNTRANSLATED */
auth_request_issuer_not_match:
'The issuer of the SAML authentication request mismatch with service provider entity ID.',
/** UNTRANSLATED */
use_saml_app_api:
'استخدم واجهة برمجة التطبيقات `[METHOD] /saml-applications(/.*)?` لتشغيل تطبيق SAML.',
saml_application_only: 'واجهة برمجة التطبيقات متاحة فقط لتطبيقات SAML.',
reach_oss_limit:
'لا يمكنك إنشاء المزيد من تطبيقات SAML لأن الحد الأقصى {{limit}} تم الوصول إليه.',
acs_url_binding_not_supported: 'يدعم فقط التوزيع HTTP-POST لاستقبال إقرارات SAML.',
can_not_delete_active_secret: 'لا يمكن حذف السر النشط.',
no_active_secret: 'لم يتم العثور على سر نشط.',
entity_id_required: 'معرف الكيان مطلوب لإنشاء بيانات وصفية.',
name_id_format_required: 'مطلوب تنسيق معرف الاسم.',
unsupported_name_id_format: 'تنسيق معرف الاسم غير مدعوم.',
missing_email_address: 'المستخدم لا يمتلك عنوان بريد إلكتروني.',
email_address_unverified: 'لم يتم التحقق من عنوان البريد الإلكتروني للمستخدم.',
invalid_certificate_pem_format: 'تنسيق شهادة PEM غير صالح.',
acs_url_required: 'عنوان URL لخدمة مستهلك الإقرارات مطلوب.',
private_key_required: 'المفتاح الخاص مطلوب.',
certificate_required: 'الشهادة مطلوبة.',
invalid_saml_request: 'طلب مصادقة SAML غير صالح.',
auth_request_issuer_not_match: 'المرسل لطلب مصادقة SAML لا يتطابق مع معرف خدمة الكيان.',
sp_initiated_saml_sso_session_not_found_in_cookies:
'Service provider initiated SAML SSO session ID not found in cookies.',
/** UNTRANSLATED */
'لم يتم العثور على معرف جلسة SAML SSO التي بدأت من قبل مزود الخدمة في ملفات تعريف الارتباط.',
sp_initiated_saml_sso_session_not_found:
'Service provider initiated SAML SSO session not found.',
/** UNTRANSLATED */
state_mismatch: '`state` mismatch.',
'لم يتم العثور على جلسة SAML SSO التي بدأت من قبل مزود الخدمة.',
state_mismatch: 'عدم تطابق `state`.',
},
};

Expand Down
3 changes: 1 addition & 2 deletions packages/phrases/src/locales/ar/errors/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ const auth = {
'لم يتم العثور على الدور المتوقع. يرجى التحقق من أدوار المستخدم والأذونات.',
jwt_sub_missing: 'القيمة `sub` مفقودة في JWT.',
require_re_authentication: 'مطلوب إعادة المصادقة لإجراء حماية.',
/** UNTRANSLATED */
exceed_token_limit: 'Token limit exceeded. Please contact your administrator.',
exceed_token_limit: 'تم تجاوز حد الرمز. يرجى الاتصال بالمسؤول الخاص بك.',
};

export default Object.freeze(auth);
3 changes: 1 addition & 2 deletions packages/phrases/src/locales/ar/errors/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ const oidc = {
insufficient_scope: 'الرمز ناقص في النطاق `{{scope}}`.',
invalid_request: 'الطلب غير صالح.',
invalid_grant: 'طلب المنحة غير صالح.',
/** UNTRANSLATED */
invalid_issuer: 'Invalid issuer.',
invalid_issuer: 'جهة إصدار غير صالحة.',
invalid_redirect_uri: '`redirect_uri` لا يتطابق مع أي من `redirect_uris` المسجلة للعميل.',
access_denied: 'تم رفض الوصول.',
invalid_target: 'مؤشر المورد غير صالح.',
Expand Down
3 changes: 1 addition & 2 deletions packages/phrases/src/locales/ar/errors/request.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
const request = {
invalid_input: 'الإدخال غير صالح. {{details}}',
general: 'حدث خطأ في الطلب.',
/** UNTRANSLATED */
range_not_satisfiable: 'Range not satisfiable.',
range_not_satisfiable: 'النطاق غير قابل للتحقق.',
};

export default Object.freeze(request);
12 changes: 4 additions & 8 deletions packages/phrases/src/locales/ar/errors/single-sign-on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@ const single_sign_on = {
duplicated_domains: 'هناك نطاقات مكررة.',
invalid_domain_format: 'تنسيق النطاق غير صالح.',
duplicate_connector_name: 'اسم الموصل مكرر. يرجى اختيار اسم مختلف.',
/** UNTRANSLATED */
idp_initiated_authentication_not_supported:
'IdP-initiated authentication is exclusively supported for SAML connectors.',
/** UNTRANSLATED */
'المصادقة التي يبدأها IdP مدعومة حصريًا لموصلات SAML.',
idp_initiated_authentication_invalid_application_type:
'Invalid application type. Only {{type}} applications are allowed.',
/** UNTRANSLATED */
'نوع التطبيق غير صالح. يُسمح فقط بتطبيقات {{type}}.',
idp_initiated_authentication_redirect_uri_not_registered:
'The redirect_uri is not registered. Please check the application settings.',
/** UNTRANSLATED */
'لم يتم تسجيل redirect_uri. يرجى التحقق من إعدادات التطبيق.',
idp_initiated_authentication_client_callback_uri_not_found:
'The client IdP-initiated authentication callback URI is not found. Please check the connector settings.',
'لم يتم العثور على URI لاستدعاء المصادقة الذي بدأه العميل IdP. يرجى التحقق من إعدادات الموصل.',
};

export default Object.freeze(single_sign_on);
Loading

0 comments on commit 8f3707a

Please sign in to comment.