0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core): sync protected app domain status (#5257)

This commit is contained in:
wangsijie 2024-01-22 11:35:46 +08:00 committed by GitHub
parent e539d999f2
commit cb5763f449
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 264 additions and 35 deletions

View file

@ -11,7 +11,7 @@ import type {
Scope,
UsersRole,
} from '@logto/schemas';
import { RoleType, ApplicationType, LogtoOidcConfigKey } from '@logto/schemas';
import { RoleType, ApplicationType, LogtoOidcConfigKey, DomainStatus } from '@logto/schemas';
import { mockId } from '#src/test-utils/nanoid.js';
@ -72,6 +72,20 @@ export const mockProtectedApplication: Omit<Application, 'protectedAppMetadata'>
createdAt: 1_645_334_775_356,
};
export const mockCustomDomain = {
domain: 'mock.blog.com',
status: DomainStatus.PendingVerification,
error: null,
dnsRecords: [],
cloudflareData: {
id: 'cloudflare-id',
status: 'active',
ssl: {
status: 'active',
},
},
};
export const mockResource: Resource = {
tenantId: 'fake_tenant',
id: 'logto_api',

View file

@ -10,23 +10,13 @@ import {
createCustomHostname,
deleteCustomHostname,
getFallbackOrigin,
getDomainStatusFromCloudflareData,
} from '#src/utils/cloudflare/index.js';
import { isSubdomainOf } from '#src/utils/domain.js';
import { clearCustomDomainCache } from '#src/utils/tenant.js';
export type DomainLibrary = ReturnType<typeof createDomainLibrary>;
const getDomainStatusFromCloudflareData = (data: CloudflareData): DomainStatus => {
switch (data.status) {
case 'active': {
return data.ssl.status === 'active' ? DomainStatus.Active : DomainStatus.PendingSsl;
}
default: {
return DomainStatus.PendingVerification;
}
}
};
export const createDomainLibrary = (queries: Queries) => {
const {
domains: { updateDomainById, insertDomain, findDomainById, deleteDomainById },

View file

@ -3,6 +3,8 @@ import { createMockUtils } from '@logto/shared/esm';
import {
mockProtectedAppConfigProviderConfig,
mockCloudflareData,
mockCustomDomain,
mockProtectedApplication,
} from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -11,6 +13,7 @@ import {
defaultProtectedAppSessionDuration,
} from '#src/routes/applications/constants.js';
import SystemContext from '#src/tenants/SystemContext.js';
import { mockFallbackOrigin } from '#src/utils/cloudflare/mock.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
@ -19,6 +22,8 @@ const { updateProtectedAppSiteConfigs } = await mockEsmWithActual(
'#src/utils/cloudflare/index.js',
() => ({
updateProtectedAppSiteConfigs: jest.fn(),
getCustomHostname: jest.fn(async () => mockCloudflareData),
getFallbackOrigin: jest.fn(async () => mockFallbackOrigin),
})
);
@ -27,14 +32,43 @@ const { createProtectedAppLibrary } = await import('./protected-app.js');
const findApplicationById = jest.fn(async (): Promise<Application> => mockProtectedApplication);
const findApplicationByProtectedAppHost = jest.fn();
const { syncAppConfigsToRemote, checkAndBuildProtectedAppData, getDefaultDomain } =
createProtectedAppLibrary(
new MockQueries({ applications: { findApplicationById, findApplicationByProtectedAppHost } })
);
const updateApplicationById = jest.fn(async (id: string, data: Partial<Application>) => ({
...mockProtectedApplication,
...data,
}));
const {
syncAppConfigsToRemote,
checkAndBuildProtectedAppData,
syncAppCustomDomainStatus,
getDefaultDomain,
} = createProtectedAppLibrary(
new MockQueries({
applications: {
findApplicationById,
findApplicationByProtectedAppHost,
updateApplicationById,
},
})
);
const protectedAppConfigProviderConfig = {
accountIdentifier: 'fake_account_id',
namespaceIdentifier: 'fake_namespace_id',
keyName: 'fake_key_name',
apiToken: '',
domain: 'protected.app',
};
beforeAll(() => {
// eslint-disable-next-line @silverhand/fp/no-mutation
SystemContext.shared.protectedAppConfigProviderConfig = mockProtectedAppConfigProviderConfig;
SystemContext.shared.protectedAppConfigProviderConfig = protectedAppConfigProviderConfig;
// eslint-disable-next-line @silverhand/fp/no-mutation
SystemContext.shared.protectedAppHostnameProviderConfig = {
zoneId: 'fake_zone_id',
apiToken: '',
blockedDomains: ['blocked.com'],
};
});
afterAll(() => {
@ -122,3 +156,42 @@ describe('getDefaultDomain()', () => {
await expect(getDefaultDomain()).resolves.toBe(mockProtectedAppConfigProviderConfig.domain);
});
});
describe('syncAppCustomDomainStatus()', () => {
afterEach(() => {
updateApplicationById.mockClear();
});
it('should return application with synced domains', async () => {
findApplicationById.mockResolvedValueOnce({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [mockCustomDomain],
},
});
await expect(syncAppCustomDomainStatus(mockProtectedApplication.id)).resolves.toMatchObject({
protectedAppMetadata: {
customDomains: [
{
...mockCustomDomain,
cloudflareData: mockCloudflareData,
},
],
},
});
expect(updateApplicationById).toHaveBeenCalled();
});
it('should skip when custom domains are empty', async () => {
findApplicationById.mockResolvedValueOnce({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [],
},
});
await syncAppCustomDomainStatus(mockProtectedApplication.id);
expect(updateApplicationById).not.toHaveBeenCalled();
});
});

View file

@ -1,4 +1,9 @@
import { DomainStatus, type Application, type ProtectedAppMetadata } from '@logto/schemas';
import {
DomainStatus,
type Application,
type ProtectedAppMetadata,
type CustomDomain,
} from '@logto/schemas';
import { isValidSubdomain } from '@logto/shared';
import { EnvSet } from '#src/env-set/index.js';
@ -13,6 +18,8 @@ import assertThat from '#src/utils/assert-that.js';
import {
createCustomHostname,
deleteProtectedAppSiteConfigs,
getCustomHostname,
getDomainStatusFromCloudflareData,
getFallbackOrigin,
updateProtectedAppSiteConfigs,
} from '#src/utils/cloudflare/index.js';
@ -170,11 +177,85 @@ export const createProtectedAppLibrary = (queries: Queries) => {
};
};
/**
* Query domain status from Cloudflare and update the data and status in the database
*/
const syncAppCustomDomainStatus = async (
applicationId: string
): Promise<
Omit<Application, 'protectedAppMetadata'> & {
protectedAppMetadata: NonNullable<Application['protectedAppMetadata']>;
}
> => {
const { protectedAppHostnameProviderConfig } = SystemContext.shared;
assertThat(protectedAppHostnameProviderConfig, 'domain.not_configured');
const application = await findApplicationById(applicationId);
const { protectedAppMetadata } = application;
assertThat(protectedAppMetadata, 'application.protected_app_not_configured');
if (!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0) {
return {
...application,
protectedAppMetadata,
};
}
const customDomains: CustomDomain[] = await Promise.all(
protectedAppMetadata.customDomains.map(async (domain) => {
assertThat(domain.cloudflareData, 'domain.cloudflare_data_missing');
const cloudflareData = await getCustomHostname(
protectedAppHostnameProviderConfig,
domain.cloudflareData.id
);
const status = getDomainStatusFromCloudflareData(cloudflareData);
const {
verification_errors: verificationErrors,
ssl: { validation_errors: sslVerificationErrors },
} = cloudflareData;
const errorMessage: string = [
...(verificationErrors ?? []),
...(sslVerificationErrors ?? []).map(({ message }) => message),
]
.filter(Boolean)
.join('\n');
return {
...domain,
cloudflareData,
errorMessage,
status,
};
})
);
const { protectedAppMetadata: updatedProtectedAppMetadata } = await updateApplicationById(
applicationId,
{
protectedAppMetadata: {
...protectedAppMetadata,
customDomains,
},
}
);
// Not expected to happen, just to make TS happy
assertThat(updatedProtectedAppMetadata, 'application.protected_app_not_configured');
return {
...application,
protectedAppMetadata: updatedProtectedAppMetadata,
};
};
return {
syncAppConfigsToRemote,
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
getDefaultDomain,
addDomainToRemote,
syncAppCustomDomainStatus,
};
};

View file

@ -1,6 +1,18 @@
{
"paths": {
"/api/applications/{id}/protected-app-metadata/custom-domains": {
"get": {
"summary": "Get the list of custom domains of the protected application.",
"description": "Get the list of custom domains of the protected application.",
"responses": {
"200": {
"description": "The domain list of the protected application."
},
"400": {
"description": "Faild to sync the domain info from remote provider."
}
}
},
"post": {
"summary": "Add a custom domain to the protected application.",
"description": "Add a custom domain to the protected application. You'll need to setup DNS record later.",

View file

@ -30,6 +30,13 @@ const mockDomainResponse = {
],
};
const addDomainToRemote = jest.fn(async () => mockDomainResponse);
const syncAppCustomDomainStatus = jest.fn(async () => ({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [mockDomainResponse],
},
}));
await mockIdGenerators();
@ -44,7 +51,7 @@ const tenantContext = new MockTenant(
},
undefined,
{
protectedApps: { addDomainToRemote },
protectedApps: { addDomainToRemote, syncAppCustomDomainStatus },
applications: { validateProtectedApplicationById: jest.fn() },
}
);
@ -60,6 +67,16 @@ describe('application protected app metadata routes', () => {
tenantContext,
});
describe('GET /applications/:applicationId/protected-app-metadata/custom-domains', () => {
it('should return domain list', async () => {
const response = await requester.get(
`/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains`
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([mockDomainResponse]);
});
});
describe('POST /applications/:applicationId/protected-app-metadata/custom-domains', () => {
it('should return 201', async () => {
const response = await requester

View file

@ -1,3 +1,4 @@
import { customDomainsGuard } from '@logto/schemas';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -19,7 +20,7 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
},
libraries: {
applications: { validateProtectedApplicationById },
protectedApps: { addDomainToRemote },
protectedApps: { addDomainToRemote, syncAppCustomDomainStatus },
},
},
]: RouterInitArgs<T>
@ -37,6 +38,25 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
return next();
});
router.get(
customDomainsPathname,
koaGuard({
params: z.object(params),
status: [200, 400, 404],
response: customDomainsGuard,
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const {
protectedAppMetadata: { customDomains },
} = await syncAppCustomDomainStatus(id);
ctx.body = customDomains ?? [];
return next();
}
);
router.post(
customDomainsPathname,
koaGuard({

View file

@ -132,3 +132,5 @@ export const deleteCustomHostname = async (auth: HostnameProviderData, identifie
handleResponse(response);
};
export { getDomainStatusFromCloudflareData } from './utils.js';

View file

@ -1,4 +1,5 @@
import { parseJson } from '@logto/connector-kit';
import { type CloudflareData, DomainStatus } from '@logto/schemas';
import { type Response } from 'got';
import { type ZodType } from 'zod';
@ -33,3 +34,19 @@ export const buildHandleResponse = (handleError: (statusCode: number) => never)
return handleResponse;
};
/**
* Parse the string response from Cloudflare API and return the domain status
* there are lots of status in Cloudflare API, but we only care about whether it's active or not
* see https://developers.cloudflare.com/api/operations/custom-hostname-for-a-zone-custom-hostname-details
*/
export const getDomainStatusFromCloudflareData = (data: CloudflareData): DomainStatus => {
switch (data.status) {
case 'active': {
return data.ssl.status === 'active' ? DomainStatus.Active : DomainStatus.PendingSsl;
}
default: {
return DomainStatus.PendingVerification;
}
}
};

View file

@ -2,6 +2,23 @@ import { z } from 'zod';
import { cloudflareDataGuard, domainDnsRecordsGuard, domainStatusGuard } from './custom-domain.js';
export const customDomainGuard = z.object({
/* The domain name, e.g app.example.com */
domain: z.string(),
/* The status of the domain in Cloudflare */
status: domainStatusGuard,
/* The error message if any */
error: z.string().nullable(),
/* The DNS records of the domain */
dnsRecords: domainDnsRecordsGuard,
/* The remote Cloudflare data */
cloudflareData: cloudflareDataGuard.nullable(),
});
export const customDomainsGuard = z.array(customDomainGuard);
export type CustomDomain = z.infer<typeof customDomainGuard>;
export const protectedAppMetadataGuard = z.object({
/* The host of the site */
host: z.string(),
@ -16,21 +33,7 @@ export const protectedAppMetadataGuard = z.object({
})
),
/* Custom domain */
customDomains: z
.object({
/* The domain name, e.g app.example.com */
domain: z.string(),
/* The status of the domain in Cloudflare */
status: domainStatusGuard,
/* The error message if any */
error: z.string().nullable(),
/* The DNS records of the domain */
dnsRecords: domainDnsRecordsGuard,
/* The remote Cloudflare data */
cloudflareData: cloudflareDataGuard.nullable(),
})
.array()
.optional(),
customDomains: customDomainsGuard.optional(),
});
export type ProtectedAppMetadata = z.infer<typeof protectedAppMetadataGuard>;