From cb5763f449bf8f2095c1d4c9c60dbf48cae5652e Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 22 Jan 2024 11:35:46 +0800 Subject: [PATCH] feat(core): sync protected app domain status (#5257) --- packages/core/src/__mocks__/index.ts | 16 +++- packages/core/src/libraries/domain.ts | 12 +-- .../core/src/libraries/protected-app.test.ts | 83 +++++++++++++++++-- packages/core/src/libraries/protected-app.ts | 83 ++++++++++++++++++- ...cation-protected-app-metadata.openapi.json | 12 +++ ...application-protected-app-metadata.test.ts | 19 ++++- .../application-protected-app-metadata.ts | 22 ++++- packages/core/src/utils/cloudflare/index.ts | 2 + packages/core/src/utils/cloudflare/utils.ts | 17 ++++ .../foundations/jsonb-types/applications.ts | 33 ++++---- 10 files changed, 264 insertions(+), 35 deletions(-) diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 9d8e5e617..46a361682 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -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 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', diff --git a/packages/core/src/libraries/domain.ts b/packages/core/src/libraries/domain.ts index b0223abba..d797fa776 100644 --- a/packages/core/src/libraries/domain.ts +++ b/packages/core/src/libraries/domain.ts @@ -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; -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 }, diff --git a/packages/core/src/libraries/protected-app.test.ts b/packages/core/src/libraries/protected-app.test.ts index 8e78e9615..e36496cee 100644 --- a/packages/core/src/libraries/protected-app.test.ts +++ b/packages/core/src/libraries/protected-app.test.ts @@ -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 => mockProtectedApplication); const findApplicationByProtectedAppHost = jest.fn(); -const { syncAppConfigsToRemote, checkAndBuildProtectedAppData, getDefaultDomain } = - createProtectedAppLibrary( - new MockQueries({ applications: { findApplicationById, findApplicationByProtectedAppHost } }) - ); +const updateApplicationById = jest.fn(async (id: string, data: Partial) => ({ + ...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(); + }); +}); diff --git a/packages/core/src/libraries/protected-app.ts b/packages/core/src/libraries/protected-app.ts index 7059bf2f7..25805daf0 100644 --- a/packages/core/src/libraries/protected-app.ts +++ b/packages/core/src/libraries/protected-app.ts @@ -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 & { + protectedAppMetadata: NonNullable; + } + > => { + 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, }; }; diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json b/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json index b116895f4..24f99d1c2 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json +++ b/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json @@ -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.", diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.test.ts b/packages/core/src/routes/applications/application-protected-app-metadata.test.ts index fc7b3b759..2b5ab21aa 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.test.ts +++ b/packages/core/src/routes/applications/application-protected-app-metadata.test.ts @@ -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 diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.ts b/packages/core/src/routes/applications/application-protected-app-metadata.ts index 2130f2220..060fa685f 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.ts +++ b/packages/core/src/routes/applications/application-protected-app-metadata.ts @@ -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 @@ -37,6 +38,25 @@ export default function applicationProtectedAppMetadataRoutes { + const { id } = ctx.guard.params; + + const { + protectedAppMetadata: { customDomains }, + } = await syncAppCustomDomainStatus(id); + + ctx.body = customDomains ?? []; + return next(); + } + ); + router.post( customDomainsPathname, koaGuard({ diff --git a/packages/core/src/utils/cloudflare/index.ts b/packages/core/src/utils/cloudflare/index.ts index 151aaa125..43d8c8060 100644 --- a/packages/core/src/utils/cloudflare/index.ts +++ b/packages/core/src/utils/cloudflare/index.ts @@ -132,3 +132,5 @@ export const deleteCustomHostname = async (auth: HostnameProviderData, identifie handleResponse(response); }; + +export { getDomainStatusFromCloudflareData } from './utils.js'; diff --git a/packages/core/src/utils/cloudflare/utils.ts b/packages/core/src/utils/cloudflare/utils.ts index 179ed338f..b4ecf8a74 100644 --- a/packages/core/src/utils/cloudflare/utils.ts +++ b/packages/core/src/utils/cloudflare/utils.ts @@ -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; + } + } +}; diff --git a/packages/schemas/src/foundations/jsonb-types/applications.ts b/packages/schemas/src/foundations/jsonb-types/applications.ts index ee6aefd75..853c9ffed 100644 --- a/packages/schemas/src/foundations/jsonb-types/applications.ts +++ b/packages/schemas/src/foundations/jsonb-types/applications.ts @@ -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; + 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;