From c66ffc644b73326f789c67c3106da78b75947d86 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Thu, 28 Mar 2024 15:31:55 +0800 Subject: [PATCH] test: add integration tests for tenant custom domain --- packages/core/src/libraries/domain.ts | 6 +- packages/core/src/utils/tenant.ts | 12 +- .../integration-tests/src/client/index.ts | 46 ++++-- .../integration-tests/src/helpers/client.ts | 8 +- .../src/tests/api/custom-domain/index.test.ts | 132 ++++++++++++++++++ 5 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 packages/integration-tests/src/tests/api/custom-domain/index.test.ts diff --git a/packages/core/src/libraries/domain.ts b/packages/core/src/libraries/domain.ts index d797fa776..4abfe321d 100644 --- a/packages/core/src/libraries/domain.ts +++ b/packages/core/src/libraries/domain.ts @@ -1,6 +1,7 @@ import { type CloudflareData, type Domain, DomainStatus } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import SystemContext from '#src/tenants/SystemContext.js'; @@ -79,7 +80,10 @@ export const createDomainLibrary = (queries: Queries) => { domain: hostname, id: generateStandardId(), cloudflareData, - status: DomainStatus.PendingVerification, + // For integration tests, automatically set the domain to active + status: EnvSet.values.isIntegrationTest + ? DomainStatus.Active + : DomainStatus.PendingVerification, dnsRecords: [ { type: 'CNAME', diff --git a/packages/core/src/utils/tenant.ts b/packages/core/src/utils/tenant.ts index 9ac387c0a..e66a7f75d 100644 --- a/packages/core/src/utils/tenant.ts +++ b/packages/core/src/utils/tenant.ts @@ -110,6 +110,12 @@ export const getTenantId = async ( return [developmentTenantId, false]; } + // Find custom domains before check multi-tenancy, custom domain should have higher priority + const customDomainTenantId = await getTenantIdFromCustomDomain(url, pool); + if (customDomainTenantId) { + return [customDomainTenantId, true]; + } + if (!isMultiTenancy) { return [defaultTenantId, false]; } @@ -118,11 +124,5 @@ export const getTenantId = async ( return [matchPathBasedTenantId(urlSet, url), false]; } - const customDomainTenantId = await getTenantIdFromCustomDomain(url, pool); - - if (customDomainTenantId) { - return [customDomainTenantId, true]; - } - return [matchDomainBasedTenantId(urlSet.endpoint, url), false]; }; diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 48552523c..e48615196 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -1,4 +1,4 @@ -import type { LogtoConfig, SignInOptions } from '@logto/node'; +import type { LogtoConfig, SignInOptions, JwtVerifier, StandardLogtoClient } from '@logto/node'; import LogtoClient from '@logto/node'; import { demoAppApplicationId } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; @@ -15,6 +15,15 @@ export const defaultConfig = { appId: demoAppApplicationId, persistAccessToken: false, }; + +class EmptyJwtVerifier implements JwtVerifier { + constructor(protected client: StandardLogtoClient) {} + + async verifyIdToken(): Promise { + // Do nothing + } +} + export default class MockClient { public rawCookies: string[] = []; protected readonly config: LogtoConfig; @@ -23,18 +32,29 @@ export default class MockClient { private navigateUrl?: string; private readonly api: KyInstance; + private readonly interactionApi: KyInstance; - constructor(config?: Partial) { + constructor( + config?: Partial, + interactionApi?: KyInstance, + /* Skip ID token JWT verification */ + skipIdTokenVerification = false + ) { this.storage = new MemoryStorage(); this.config = { ...defaultConfig, ...config }; - this.api = ky.extend({ prefixUrl: this.config.endpoint + '/api' }); + this.interactionApi = interactionApi ?? ky; + this.api = this.interactionApi.extend({ prefixUrl: this.config.endpoint + '/api' }); - this.logto = new LogtoClient(this.config, { - navigate: (url: string) => { - this.navigateUrl = url; + this.logto = new LogtoClient( + this.config, + { + navigate: (url: string) => { + this.navigateUrl = url; + }, + storage: this.storage, }, - storage: this.storage, - }); + skipIdTokenVerification ? (client) => new EmptyJwtVerifier(client) : undefined + ); } // TODO: Rename to sessionCookies or something accurate @@ -71,7 +91,7 @@ export default class MockClient { ); // Mock SDK sign-in navigation - const response = await ky(this.navigateUrl, { + const response = await this.interactionApi(this.navigateUrl, { redirect: 'manual', throwHttpErrors: false, }); @@ -102,7 +122,7 @@ export default class MockClient { new Error('SignIn or Register failed') ); - const authResponse = await ky.get(redirectTo, { + const authResponse = await this.interactionApi.get(redirectTo, { headers: { cookie: this.interactionCookie, }, @@ -141,7 +161,7 @@ export default class MockClient { } await this.logto.signOut(postSignOutRedirectUri); - await ky(this.navigateUrl); + await this.interactionApi(this.navigateUrl); } public async isAuthenticated() { @@ -179,7 +199,7 @@ export default class MockClient { assert(this.interactionCookie, new Error('Session not found')); assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found')); - const consentResponse = await ky.get(`${this.config.endpoint}/consent`, { + const consentResponse = await this.interactionApi.get(`${this.config.endpoint}/consent`, { headers: { cookie: this.interactionCookie, }, @@ -194,7 +214,7 @@ export default class MockClient { throw new Error('Consent failed'); } - const authCodeResponse = await ky.get(redirectTo, { + const authCodeResponse = await this.interactionApi.get(redirectTo, { headers: { cookie: this.interactionCookie, }, diff --git a/packages/integration-tests/src/helpers/client.ts b/packages/integration-tests/src/helpers/client.ts index 652f113f8..6e78edffe 100644 --- a/packages/integration-tests/src/helpers/client.ts +++ b/packages/integration-tests/src/helpers/client.ts @@ -1,14 +1,18 @@ import type { LogtoConfig, SignInOptions } from '@logto/node'; import { assert } from '@silverhand/essentials'; +import { type KyInstance } from 'ky'; import MockClient from '#src/client/index.js'; export const initClient = async ( config?: Partial, redirectUri?: string, - options: Omit = {} + options: Omit & { + interactionApi?: KyInstance; + skipIdTokenVerification?: boolean; + } = {} ) => { - const client = new MockClient(config); + const client = new MockClient(config, options.interactionApi, options.skipIdTokenVerification); await client.initSession(redirectUri, options); assert(client.interactionCookie, new Error('Session not found')); diff --git a/packages/integration-tests/src/tests/api/custom-domain/index.test.ts b/packages/integration-tests/src/tests/api/custom-domain/index.test.ts new file mode 100644 index 000000000..20116ce87 --- /dev/null +++ b/packages/integration-tests/src/tests/api/custom-domain/index.test.ts @@ -0,0 +1,132 @@ +import { ConnectorType, InteractionEvent } from '@logto/schemas'; +import ky from 'ky'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { createDomain, deleteDomain, getDomains } from '#src/api/domain.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { initClient, logoutClient, processSession } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import { generateDomain } from '#src/utils.js'; + +const localHostname = '127.0.0.1'; +const localPort = '3001'; + +const overrideOrigin = (url: string | URL): URL => { + const newUrl = new URL(url.toString()); + /* eslint-disable @silverhand/fp/no-mutation */ + newUrl.hostname = localHostname; + newUrl.port = localPort; + /* eslint-enable @silverhand/fp/no-mutation */ + return newUrl; +}; + +describe('Using custom domain', () => { + const domainName = 'auth.example.com'; + + beforeAll(async () => { + await createDomain(domainName); + }); + + afterAll(async () => { + const domains = await getDomains(); + await Promise.all(domains.map(async (domain) => deleteDomain(domain.id))); + }); + + describe('OIDC discovery', () => { + it('should return OIDC configuration with custom domain', async () => { + const response = ky( + `http://${localHostname}:${localPort}/oidc/.well-known/openid-configuration`, + { + headers: { + Host: domainName, + }, + } + ); + + await expect(response.json<{ issuer: string }>()).resolves.toMatchObject({ + issuer: `http://${domainName}/oidc`, + }); + }); + + it('should fallback to localhost for unknown domain', async () => { + const response = ky('http://127.0.0.1:3001/oidc/.well-known/openid-configuration', { + headers: { + Host: generateDomain(), + }, + }); + + await expect(response.json<{ issuer: string }>()).resolves.toMatchObject({ + issuer: 'http://localhost:3001/oidc', + }); + }); + }); + + describe('Sign in', () => { + const originalFetch = global.fetch; + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + await clearConnectorsByTypes([ConnectorType.Sms, ConnectorType.Email]); + await setSmsConnector(); + await setEmailConnector(); + // Intercept fetch request (used in Logto SDK) to custom domain and redirect to localhost + // eslint-disable-next-line @silverhand/fp/no-mutation + global.fetch = async (input, init) => { + if (typeof input === 'string' && input.startsWith(`http://${domainName}`)) { + return originalFetch(overrideOrigin(input).toString(), { + ...init, + headers: { + ...init?.headers, + Host: domainName, + }, + }); + } + return originalFetch(input, init); + }; + }); + + afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + global.fetch = originalFetch; + }); + + it('sign-in with custom domain endpoint', async () => { + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const client = await initClient({ endpoint: `http://${domainName}` }, undefined, { + skipIdTokenVerification: true, + interactionApi: ky.create({ + hooks: { + beforeRequest: [ + (request) => { + const url = new URL(request.url); + if (url.hostname === domainName) { + request.headers.set('Host', domainName); + return new Request(overrideOrigin(url).toString(), request); + } + }, + ], + }, + }), + }); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + username: userProfile.username, + password: userProfile.password, + }, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); + + await deleteUser(user.id); + }); + }); +});