diff --git a/.changeset/wise-cows-scream.md b/.changeset/wise-cows-scream.md new file mode 100644 index 000000000..33ce54683 --- /dev/null +++ b/.changeset/wise-cows-scream.md @@ -0,0 +1,6 @@ +--- +"@logto/console": patch +"@logto/core": patch +--- + +apply custom domain to SAML SSO and SAML applications diff --git a/packages/console/src/mdx-components/SsoSamlSpMetadata/index.tsx b/packages/console/src/mdx-components/SsoSamlSpMetadata/index.tsx index 3d04c6110..fe286b73c 100644 --- a/packages/console/src/mdx-components/SsoSamlSpMetadata/index.tsx +++ b/packages/console/src/mdx-components/SsoSamlSpMetadata/index.tsx @@ -1,9 +1,11 @@ +import { conditionalString } from '@silverhand/essentials'; import { useContext, useMemo } from 'react'; import { z } from 'zod'; import { SsoConnectorContext } from '@/contexts/SsoConnectorContextProvider'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; import FormField from '@/ds-components/FormField'; +import useCustomDomain from '@/hooks/use-custom-domain'; import styles from './index.module.scss'; @@ -20,6 +22,7 @@ const samlProviderConfigGuard = z.object({ function SsoSamlSpMetadata() { const { ssoConnector } = useContext(SsoConnectorContext); + const { applyDomain: applyCustomDomain } = useCustomDomain(); const serviceProviderMetadata = useMemo(() => { if (!ssoConnector) { @@ -49,7 +52,9 @@ function SsoSamlSpMetadata() { diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index eee74ee95..81645d1db 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -43,6 +43,7 @@ export class EnvSet { #pool: Optional; #oidc: Optional>>; + #endpoint: Optional; constructor( public readonly tenantId: string, @@ -65,6 +66,14 @@ export class EnvSet { return this.#oidc; } + get endpoint() { + if (!this.#endpoint) { + return throwNotLoadedError(); + } + + return this.#endpoint; + } + async load(customDomain?: string) { const pool = await createPoolByEnv( this.databaseUrl, @@ -81,10 +90,10 @@ export class EnvSet { }); const oidcConfigs = await getOidcConfigs(consoleLog); - const endpoint = customDomain + this.#endpoint = customDomain ? new URL(customDomain) : getTenantEndpoint(this.tenantId, EnvSet.values); - this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').href, oidcConfigs); + this.#oidc = await loadOidcValues(appendPath(this.#endpoint, '/oidc').href, oidcConfigs); } async end() { diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index 4d100d989..0dddab7a3 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -198,7 +198,7 @@ export default function authnRoutes( // Will throw ConnectorError if the config is invalid const connectorInstance = new ssoConnectorFactories[providerName].constructor( connectorData, - tenantId + envSet.endpoint ); assertThat(connectorInstance instanceof SamlConnector, 'connector.unexpected_type'); diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 45090c315..c1dbf0c6f 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -8,7 +8,7 @@ import { wellConfiguredSsoConnector, mockSamlSsoConnector, } from '#src/__mocks__/sso.js'; -import { EnvSet } from '#src/env-set/index.js'; +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; @@ -74,7 +74,8 @@ jest jest .spyOn(ssoConnectorFactories.SAML, 'constructor') .mockImplementation( - (data: SingleSignOnConnectorData) => new MockSamlSsoConnector(data, 'tenantId') + (data: SingleSignOnConnectorData) => + new MockSamlSsoConnector(data, getTenantEndpoint('tenantId', EnvSet.values)) ); const { diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index 86a2fbda4..bdf7ad9f8 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -39,7 +39,7 @@ type AuthorizationUrlPayload = z.infer; export const getSsoAuthorizationUrl = async ( ctx: WithLogContext, - { provider, id: tenantId, queries }: TenantContext, + { provider, queries, envSet }: TenantContext, connectorData: SupportedSsoConnector, payload: AuthorizationUrlPayload ): Promise => { @@ -58,7 +58,7 @@ export const getSsoAuthorizationUrl = async ( // Will throw ConnectorError if the config is invalid const connectorInstance = new ssoConnectorFactories[providerName].constructor( connectorData, - tenantId + envSet.endpoint ); assertThat(payload, 'session.insufficient_info'); @@ -143,7 +143,7 @@ type SsoAuthenticationResult = { */ export const verifySsoIdentity = async ( ctx: WithLogContext, - { provider, id: tenantId }: TenantContext, + { provider, envSet }: TenantContext, connectorData: SupportedSsoConnector, data: Record ): Promise => { @@ -159,7 +159,7 @@ export const verifySsoIdentity = async ( // Will throw ConnectorError if the config is invalid const connectorInstance = new ssoConnectorFactories[providerName].constructor( connectorData, - tenantId + envSet.endpoint ); const issuer = await connectorInstance.getIssuer(); const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data); diff --git a/packages/core/src/routes/saml-application/anonymous.ts b/packages/core/src/routes/saml-application/anonymous.ts index c25d0b700..18fc4c1e0 100644 --- a/packages/core/src/routes/saml-application/anonymous.ts +++ b/packages/core/src/routes/saml-application/anonymous.ts @@ -27,7 +27,7 @@ const samlApplicationSignInCallbackQueryParametersGuard = z .partial(); export default function samlApplicationAnonymousRoutes( - ...[router, { id: tenantId, libraries, queries, envSet }]: RouterInitArgs + ...[router, { queries, envSet }]: RouterInitArgs ) { const { samlApplications: { getSamlApplicationDetailsById }, @@ -50,7 +50,7 @@ export default function samlApplicationAnonymousRoutes( - ...[router, { id: tenantId, queries, libraries }]: RouterInitArgs + ...[router, { id: tenantId, queries, libraries, envSet }]: RouterInitArgs ) { const { applications: { @@ -92,10 +92,7 @@ export default function samlApplicationRoutes( const id = generateStandardId(); // Set the default redirect URI for SAML apps when creating a new SAML app. - const redirectUri = getSamlAppCallbackUrl( - getTenantEndpoint(tenantId, EnvSet.values), - id - ).toString(); + const redirectUri = getSamlAppCallbackUrl(envSet.endpoint, id).toString(); const application = await insertApplication( removeUndefinedKeys({ diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index 0c79800ba..5ff271389 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -41,6 +41,7 @@ export default function singleSignOnConnectorsRoutes - fetchConnectorProviderDetails(connector, tenantId, ctx.locale) + fetchConnectorProviderDetails(connector, envSet.endpoint, ctx.locale) ) ); @@ -189,7 +190,7 @@ export default function singleSignOnConnectorsRoutes { describe('fetchConnectorProviderDetails', () => { it('providerConfig should be undefined if connector config is invalid', async () => { const connector = { ...mockSsoConnector, config: { clientId: 'foo' } }; - const result = await fetchConnectorProviderDetails(connector, mockTenantId, 'en'); + const result = await fetchConnectorProviderDetails( + connector, + getTenantEndpoint(mockTenantId, EnvSet.values), + 'en' + ); expect(result).toMatchObject( expect.objectContaining({ @@ -74,7 +79,11 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error')); - const result = await fetchConnectorProviderDetails(connector, mockTenantId, 'en'); + const result = await fetchConnectorProviderDetails( + connector, + getTenantEndpoint(mockTenantId, EnvSet.values), + 'en' + ); expect(result).toMatchObject( expect.objectContaining({ @@ -93,7 +102,11 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' }); - const result = await fetchConnectorProviderDetails(connector, mockTenantId, 'en'); + const result = await fetchConnectorProviderDetails( + connector, + getTenantEndpoint(mockTenantId, EnvSet.values), + 'en' + ); expect(result).toMatchObject( expect.objectContaining({ diff --git a/packages/core/src/routes/sso-connector/utils.ts b/packages/core/src/routes/sso-connector/utils.ts index db2edb18c..b090ff022 100644 --- a/packages/core/src/routes/sso-connector/utils.ts +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -57,7 +57,7 @@ export const parseConnectorConfig = (providerName: SsoProviderName, config: Json export const fetchConnectorProviderDetails = async ( connector: SupportedSsoConnector, - tenantId: string, + endpoint: URL, locale: string ): Promise => { const { providerName } = connector; @@ -69,7 +69,7 @@ export const fetchConnectorProviderDetails = async ( Return undefined if failed to fetch or parse the config. */ const providerConfig = await trySafe(async () => { - const instance = new constructor(connector, tenantId); + const instance = new constructor(connector, endpoint); return instance.getConfig(); }); @@ -91,11 +91,11 @@ export const fetchConnectorProviderDetails = async ( */ export const validateConnectorConfigConnectionStatus = async ( connector: SingleSignOnConnectorData, - tenantId: string + endpoint: URL ) => { const { providerName } = connector; const { constructor } = ssoConnectorFactories[providerName]; - const instance = new constructor(connector, tenantId); + const instance = new constructor(connector, endpoint); // SAML connector's idpMetadata is optional (safely catch by the getConfig method), we need to force fetch the IdP metadata here if (instance instanceof SamlConnector) { diff --git a/packages/core/src/saml-application/SamlApplication/index.test.ts b/packages/core/src/saml-application/SamlApplication/index.test.ts index e462ce81c..b7edf0a30 100644 --- a/packages/core/src/saml-application/SamlApplication/index.test.ts +++ b/packages/core/src/saml-application/SamlApplication/index.test.ts @@ -2,6 +2,8 @@ import { UserScope, ReservedScope } from '@logto/core-kit'; import { NameIdFormat } from '@logto/schemas'; import nock from 'nock'; +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; + import { SamlApplication } from './index.js'; const { jest } = import.meta; @@ -58,7 +60,10 @@ describe('SamlApplication', () => { beforeEach(() => { // @ts-expect-error // eslint-disable-next-line @silverhand/fp/no-mutation - samlApp = new TestSamlApplication(mockDetails, mockSamlApplicationId, mockIssuer, mockTenantId); + samlApp = new TestSamlApplication(mockDetails, mockSamlApplicationId, { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + }); nock(mockIssuer).get('/.well-known/openid-configuration').reply(200, { token_endpoint: mockTokenEndpoint, @@ -188,8 +193,10 @@ describe('SamlApplication', () => { attributeMapping: {}, }, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const scopes = app.exposedGetScopesFromAttributeMapping(); @@ -207,8 +214,10 @@ describe('SamlApplication', () => { attributeMapping: {}, }, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const scopes = app.exposedGetScopesFromAttributeMapping(); @@ -228,8 +237,10 @@ describe('SamlApplication', () => { }, }, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const scopes = app.exposedGetScopesFromAttributeMapping(); @@ -250,8 +261,10 @@ describe('SamlApplication', () => { }, }, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const scopes = app.exposedGetScopesFromAttributeMapping(); @@ -277,8 +290,10 @@ describe('SamlApplication', () => { }, }, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const scopes = app.exposedGetScopesFromAttributeMapping(); @@ -308,8 +323,10 @@ describe('SamlApplication', () => { // @ts-expect-error mockDetailsWithMapping, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const template = samlApp.exposedBuildLoginResponseTemplate(); @@ -353,8 +370,10 @@ describe('SamlApplication', () => { // @ts-expect-error mockDetailsWithMapping, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const tagValues = samlApp.exposedBuildSamlAttributesTagValues(mockUser); @@ -382,8 +401,10 @@ describe('SamlApplication', () => { // @ts-expect-error mockDetailsWithMapping, mockSamlApplicationId, - mockIssuer, - mockTenantId + { + oidc: { issuer: mockIssuer }, + endpoint: getTenantEndpoint(mockTenantId, EnvSet.values), + } ); const tagValues = samlApp.exposedBuildSamlAttributesTagValues(mockUser); diff --git a/packages/core/src/saml-application/SamlApplication/index.ts b/packages/core/src/saml-application/SamlApplication/index.ts index dab81b3d6..d995d6f23 100644 --- a/packages/core/src/saml-application/SamlApplication/index.ts +++ b/packages/core/src/saml-application/SamlApplication/index.ts @@ -16,7 +16,7 @@ import { XMLValidator } from 'fast-xml-parser'; import saml from 'samlify'; import { ZodError, z } from 'zod'; -import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; +import { type EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { buildSingleSignOnUrl, @@ -109,7 +109,8 @@ class SamlApplicationConfig { export class SamlApplication { public config: SamlApplicationConfig; - protected tenantEndpoint: URL; + protected endpoint: URL; + protected issuer: string; protected oidcConfig?: CamelCaseKeys; private _idp?: saml.IdentityProviderInstance; @@ -118,11 +119,11 @@ export class SamlApplication { constructor( details: SamlApplicationDetails, protected samlApplicationId: string, - protected issuer: string, - tenantId: string + protected envSet: EnvSet ) { this.config = new SamlApplicationConfig(details); - this.tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values); + this.issuer = envSet.oidc.issuer; + this.endpoint = envSet.endpoint; } public get idp(): saml.IdentityProviderInstance { @@ -146,7 +147,7 @@ export class SamlApplication { } public get samlAppCallbackUrl() { - return getSamlAppCallbackUrl(this.tenantEndpoint, this.samlApplicationId).toString(); + return getSamlAppCallbackUrl(this.endpoint, this.samlApplicationId).toString(); } public async parseLoginRequest( @@ -484,10 +485,10 @@ export class SamlApplication { private buildIdpConfig(): SamlIdentityProviderConfig { return { - entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId), + entityId: buildSamlIdentityProviderEntityId(this.endpoint, this.samlApplicationId), privateKey: this.config.privateKey, certificate: this.config.certificate, - singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId), + singleSignOnUrl: buildSingleSignOnUrl(this.endpoint, this.samlApplicationId), nameIdFormat: this.config.nameIdFormat, encryptSamlAssertion: this.config.encryption?.encryptAssertion ?? false, }; diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts index 4c0a70628..5a39dc726 100644 --- a/packages/core/src/sso/SamlConnector/index.ts +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -3,8 +3,6 @@ import { conditional, type Optional } from '@silverhand/essentials'; import { XMLValidator } from 'fast-xml-parser'; import * as saml from 'samlify'; -import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; - import { SsoConnectorConfigErrorCodes, SsoConnectorError, @@ -58,18 +56,13 @@ class SamlConnector { // Allow _idpConfig input to be undefined when constructing the connector. constructor( - tenantId: string, + endpoint: URL, ssoConnectorId: string, private readonly _idpConfig: SamlConnectorConfig | undefined ) { - const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values); + const assertionConsumerServiceUrl = buildAssertionConsumerServiceUrl(endpoint, ssoConnectorId); - const assertionConsumerServiceUrl = buildAssertionConsumerServiceUrl( - tenantEndpoint, - ssoConnectorId - ); - - const spEntityId = buildSpEntityId(tenantEndpoint, ssoConnectorId); + const spEntityId = buildSpEntityId(endpoint, ssoConnectorId); this.serviceProviderMetadata = { entityId: spEntityId, diff --git a/packages/core/src/sso/SamlSsoConnector/index.test.ts b/packages/core/src/sso/SamlSsoConnector/index.test.ts index 9fd9bf240..7b9949dd4 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.test.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.test.ts @@ -1,6 +1,7 @@ import { SsoProviderName } from '@logto/schemas'; import { mockSsoConnector as _mockSsoConnector } from '#src/__mocks__/sso.js'; +import { getTenantEndpoint, EnvSet } from '#src/env-set/index.js'; import { SsoConnectorConfigErrorCodes, @@ -17,7 +18,10 @@ describe('SamlSsoConnector', () => { it('constructor should work properly', () => { // eslint-disable-next-line unicorn/consistent-function-scoping const createSamlSsoConnector = () => - new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant'); + new samlSsoConnectorFactory.constructor( + mockSsoConnector, + getTenantEndpoint('default_tenant', EnvSet.values) + ); expect(createSamlSsoConnector).not.toThrow(); }); @@ -26,7 +30,7 @@ describe('SamlSsoConnector', () => { const temporaryMockSsoConnector = { ...mockSsoConnector, config: { metadata: 123 } }; const connector = new samlSsoConnectorFactory.constructor( temporaryMockSsoConnector, - 'default_tenant' + getTenantEndpoint('default_tenant', EnvSet.values) ); const { serviceProvider, identityProvider } = await connector.getConfig(); @@ -36,7 +40,10 @@ describe('SamlSsoConnector', () => { }); it('should throw error on calling getIdpMetadata, if the config is invalid', async () => { - const connector = new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant'); + const connector = new samlSsoConnectorFactory.constructor( + mockSsoConnector, + getTenantEndpoint('default_tenant', EnvSet.values) + ); await expect(async () => connector.getSamlIdpMetadata()).rejects.toThrow( new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { @@ -59,7 +66,7 @@ describe('SamlSsoConnector', () => { const connector = new samlSsoConnectorFactory.constructor( temporaryMockSsoConnector, - 'default_tenant' + getTenantEndpoint('default_tenant', EnvSet.values) ); expect(connector.idpConfig).toEqual(config); diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts index d6ff84341..5c3152ea8 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -32,12 +32,12 @@ import { export class SamlSsoConnector extends SamlConnector implements SingleSignOn { constructor( readonly data: SingleSignOnConnectorData, - tenantId: string + endpoint: URL ) { const parseConfigResult = samlConnectorConfigGuard.safeParse(data.config); // Fallback to undefined if config is invalid - super(tenantId, data.id, conditional(parseConfigResult.success && parseConfigResult.data)); + super(endpoint, data.id, conditional(parseConfigResult.success && parseConfigResult.data)); } async getIssuer() {