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