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:
parent
e539d999f2
commit
cb5763f449
10 changed files with 264 additions and 35 deletions
|
@ -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',
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -132,3 +132,5 @@ export const deleteCustomHostname = async (auth: HostnameProviderData, identifie
|
|||
|
||||
handleResponse(response);
|
||||
};
|
||||
|
||||
export { getDomainStatusFromCloudflareData } from './utils.js';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue