mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
test: add integration tests for tenant custom domain
This commit is contained in:
parent
26c215fbdc
commit
c66ffc644b
5 changed files with 182 additions and 22 deletions
|
@ -1,6 +1,7 @@
|
||||||
import { type CloudflareData, type Domain, DomainStatus } from '@logto/schemas';
|
import { type CloudflareData, type Domain, DomainStatus } from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import SystemContext from '#src/tenants/SystemContext.js';
|
import SystemContext from '#src/tenants/SystemContext.js';
|
||||||
|
@ -79,7 +80,10 @@ export const createDomainLibrary = (queries: Queries) => {
|
||||||
domain: hostname,
|
domain: hostname,
|
||||||
id: generateStandardId(),
|
id: generateStandardId(),
|
||||||
cloudflareData,
|
cloudflareData,
|
||||||
status: DomainStatus.PendingVerification,
|
// For integration tests, automatically set the domain to active
|
||||||
|
status: EnvSet.values.isIntegrationTest
|
||||||
|
? DomainStatus.Active
|
||||||
|
: DomainStatus.PendingVerification,
|
||||||
dnsRecords: [
|
dnsRecords: [
|
||||||
{
|
{
|
||||||
type: 'CNAME',
|
type: 'CNAME',
|
||||||
|
|
|
@ -110,6 +110,12 @@ export const getTenantId = async (
|
||||||
return [developmentTenantId, false];
|
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) {
|
if (!isMultiTenancy) {
|
||||||
return [defaultTenantId, false];
|
return [defaultTenantId, false];
|
||||||
}
|
}
|
||||||
|
@ -118,11 +124,5 @@ export const getTenantId = async (
|
||||||
return [matchPathBasedTenantId(urlSet, url), false];
|
return [matchPathBasedTenantId(urlSet, url), false];
|
||||||
}
|
}
|
||||||
|
|
||||||
const customDomainTenantId = await getTenantIdFromCustomDomain(url, pool);
|
|
||||||
|
|
||||||
if (customDomainTenantId) {
|
|
||||||
return [customDomainTenantId, true];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [matchDomainBasedTenantId(urlSet.endpoint, url), false];
|
return [matchDomainBasedTenantId(urlSet.endpoint, url), false];
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 LogtoClient from '@logto/node';
|
||||||
import { demoAppApplicationId } from '@logto/schemas';
|
import { demoAppApplicationId } from '@logto/schemas';
|
||||||
import type { Nullable, Optional } from '@silverhand/essentials';
|
import type { Nullable, Optional } from '@silverhand/essentials';
|
||||||
|
@ -15,6 +15,15 @@ export const defaultConfig = {
|
||||||
appId: demoAppApplicationId,
|
appId: demoAppApplicationId,
|
||||||
persistAccessToken: false,
|
persistAccessToken: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class EmptyJwtVerifier implements JwtVerifier {
|
||||||
|
constructor(protected client: StandardLogtoClient) {}
|
||||||
|
|
||||||
|
async verifyIdToken(): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class MockClient {
|
export default class MockClient {
|
||||||
public rawCookies: string[] = [];
|
public rawCookies: string[] = [];
|
||||||
protected readonly config: LogtoConfig;
|
protected readonly config: LogtoConfig;
|
||||||
|
@ -23,18 +32,29 @@ export default class MockClient {
|
||||||
|
|
||||||
private navigateUrl?: string;
|
private navigateUrl?: string;
|
||||||
private readonly api: KyInstance;
|
private readonly api: KyInstance;
|
||||||
|
private readonly interactionApi: KyInstance;
|
||||||
|
|
||||||
constructor(config?: Partial<LogtoConfig>) {
|
constructor(
|
||||||
|
config?: Partial<LogtoConfig>,
|
||||||
|
interactionApi?: KyInstance,
|
||||||
|
/* Skip ID token JWT verification */
|
||||||
|
skipIdTokenVerification = false
|
||||||
|
) {
|
||||||
this.storage = new MemoryStorage();
|
this.storage = new MemoryStorage();
|
||||||
this.config = { ...defaultConfig, ...config };
|
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, {
|
this.logto = new LogtoClient(
|
||||||
navigate: (url: string) => {
|
this.config,
|
||||||
this.navigateUrl = url;
|
{
|
||||||
|
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
|
// TODO: Rename to sessionCookies or something accurate
|
||||||
|
@ -71,7 +91,7 @@ export default class MockClient {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock SDK sign-in navigation
|
// Mock SDK sign-in navigation
|
||||||
const response = await ky(this.navigateUrl, {
|
const response = await this.interactionApi(this.navigateUrl, {
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
});
|
});
|
||||||
|
@ -102,7 +122,7 @@ export default class MockClient {
|
||||||
new Error('SignIn or Register failed')
|
new Error('SignIn or Register failed')
|
||||||
);
|
);
|
||||||
|
|
||||||
const authResponse = await ky.get(redirectTo, {
|
const authResponse = await this.interactionApi.get(redirectTo, {
|
||||||
headers: {
|
headers: {
|
||||||
cookie: this.interactionCookie,
|
cookie: this.interactionCookie,
|
||||||
},
|
},
|
||||||
|
@ -141,7 +161,7 @@ export default class MockClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.logto.signOut(postSignOutRedirectUri);
|
await this.logto.signOut(postSignOutRedirectUri);
|
||||||
await ky(this.navigateUrl);
|
await this.interactionApi(this.navigateUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async isAuthenticated() {
|
public async isAuthenticated() {
|
||||||
|
@ -179,7 +199,7 @@ export default class MockClient {
|
||||||
assert(this.interactionCookie, new Error('Session not found'));
|
assert(this.interactionCookie, new Error('Session not found'));
|
||||||
assert(this.interactionCookie.includes('_session.sig'), 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: {
|
headers: {
|
||||||
cookie: this.interactionCookie,
|
cookie: this.interactionCookie,
|
||||||
},
|
},
|
||||||
|
@ -194,7 +214,7 @@ export default class MockClient {
|
||||||
throw new Error('Consent failed');
|
throw new Error('Consent failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authCodeResponse = await ky.get(redirectTo, {
|
const authCodeResponse = await this.interactionApi.get(redirectTo, {
|
||||||
headers: {
|
headers: {
|
||||||
cookie: this.interactionCookie,
|
cookie: this.interactionCookie,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import type { LogtoConfig, SignInOptions } from '@logto/node';
|
import type { LogtoConfig, SignInOptions } from '@logto/node';
|
||||||
import { assert } from '@silverhand/essentials';
|
import { assert } from '@silverhand/essentials';
|
||||||
|
import { type KyInstance } from 'ky';
|
||||||
|
|
||||||
import MockClient from '#src/client/index.js';
|
import MockClient from '#src/client/index.js';
|
||||||
|
|
||||||
export const initClient = async (
|
export const initClient = async (
|
||||||
config?: Partial<LogtoConfig>,
|
config?: Partial<LogtoConfig>,
|
||||||
redirectUri?: string,
|
redirectUri?: string,
|
||||||
options: Omit<SignInOptions, 'redirectUri'> = {}
|
options: Omit<SignInOptions, 'redirectUri'> & {
|
||||||
|
interactionApi?: KyInstance;
|
||||||
|
skipIdTokenVerification?: boolean;
|
||||||
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const client = new MockClient(config);
|
const client = new MockClient(config, options.interactionApi, options.skipIdTokenVerification);
|
||||||
await client.initSession(redirectUri, options);
|
await client.initSession(redirectUri, options);
|
||||||
assert(client.interactionCookie, new Error('Session not found'));
|
assert(client.interactionCookie, new Error('Session not found'));
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue