0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add custom domain for protected app (#5254)

This commit is contained in:
wangsijie 2024-01-19 16:41:01 +08:00 committed by GitHub
parent ea407f19fa
commit ef29f490af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 265 additions and 9 deletions

View file

@ -44,7 +44,9 @@ export const mockApplication: Application = {
createdAt: 1_645_334_775_356,
};
export const mockProtectedApplication: Application = {
export const mockProtectedApplication: Omit<Application, 'protectedAppMetadata'> & {
protectedAppMetadata: NonNullable<Application['protectedAppMetadata']>;
} = {
tenantId: 'fake_tenant',
id: 'mock-protected-app',
secret: mockId,

View file

@ -4,6 +4,7 @@ import {
type Scope,
ApplicationUserConsentScopeType,
getManagementApiResourceIndicator,
ApplicationType,
} from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
@ -233,6 +234,19 @@ export const createApplicationLibrary = (queries: Queries) => {
);
};
// Guard application exists and is a protected app
const validateProtectedApplicationById = async (applicationId: string) => {
const application = await findApplicationById(applicationId);
assertThat(
application.type === ApplicationType.Protected,
new RequestError({
code: 'application.protected_application_only',
status: 422,
})
);
};
return {
validateThirdPartyApplicationById,
findApplicationScopesForResourceIndicator,
@ -243,5 +257,6 @@ export const createApplicationLibrary = (queries: Queries) => {
getApplicationUserConsentScopes,
deleteApplicationUserConsentScopesByTypeAndScopeId,
validateUserConsentOrganizationMembership,
validateProtectedApplicationById,
};
};

View file

@ -1,3 +1,4 @@
import { type Application } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import {
@ -24,7 +25,7 @@ const { updateProtectedAppSiteConfigs } = await mockEsmWithActual(
const { MockQueries } = await import('#src/test-utils/tenant.js');
const { createProtectedAppLibrary } = await import('./protected-app.js');
const findApplicationById = jest.fn(async () => mockProtectedApplication);
const findApplicationById = jest.fn(async (): Promise<Application> => mockProtectedApplication);
const findApplicationByProtectedAppHost = jest.fn();
const { syncAppConfigsToRemote, checkAndBuildProtectedAppData, getDefaultDomain } =
createProtectedAppLibrary(
@ -61,7 +62,7 @@ describe('syncAppConfigsToRemote()', () => {
const { protectedAppMetadata, id, secret } = mockProtectedApplication;
expect(updateProtectedAppSiteConfigs).toHaveBeenCalledWith(
mockProtectedAppConfigProviderConfig,
protectedAppMetadata?.host,
protectedAppMetadata.host,
{
...protectedAppMetadata,
sdkConfig: {

View file

@ -1,4 +1,4 @@
import { type Application } from '@logto/schemas';
import { DomainStatus, type Application, type ProtectedAppMetadata } from '@logto/schemas';
import { isValidSubdomain } from '@logto/shared';
import { EnvSet } from '#src/env-set/index.js';
@ -11,9 +11,12 @@ import type Queries from '#src/tenants/Queries.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import {
createCustomHostname,
deleteProtectedAppSiteConfigs,
getFallbackOrigin,
updateProtectedAppSiteConfigs,
} from '#src/utils/cloudflare/index.js';
import { isSubdomainOf } from '#src/utils/domain.js';
export type ProtectedAppLibrary = ReturnType<typeof createProtectedAppLibrary>;
@ -24,6 +27,13 @@ const getProviderConfig = async () => {
return protectedAppConfigProviderConfig;
};
const getHostnameProviderConfig = async () => {
const { protectedAppHostnameProviderConfig } = SystemContext.shared;
assertThat(protectedAppHostnameProviderConfig, 'application.protected_app_not_configured');
return protectedAppHostnameProviderConfig;
};
const deleteRemoteAppConfigs = async (host: string): Promise<void> => {
if (EnvSet.values.isIntegrationTest) {
return;
@ -42,9 +52,45 @@ const getDefaultDomain = async () => {
return domain;
};
/**
* Call Cloudflare API to add the domain (custom hostname) to the remote
* and get the DNS records to be added to the DNS provider
*/
const addDomainToRemote = async (
hostname: string
): Promise<NonNullable<ProtectedAppMetadata['customDomains']>[number]> => {
const hostnameProviderConfig = await getHostnameProviderConfig();
const { blockedDomains } = hostnameProviderConfig;
assertThat(
!(blockedDomains ?? []).some(
(domain) => hostname === domain || isSubdomainOf(hostname, domain)
),
'domain.domain_is_not_allowed'
);
const [fallbackOrigin, cloudflareData] = await Promise.all([
getFallbackOrigin(hostnameProviderConfig),
createCustomHostname(hostnameProviderConfig, hostname),
]);
return {
domain: hostname,
cloudflareData,
status: DomainStatus.PendingVerification,
error: null,
dnsRecords: [
{
type: 'CNAME',
name: hostname,
value: fallbackOrigin,
},
],
};
};
export const createProtectedAppLibrary = (queries: Queries) => {
const {
applications: { findApplicationById, findApplicationByProtectedAppHost },
applications: { findApplicationById, findApplicationByProtectedAppHost, updateApplicationById },
} = queries;
const syncAppConfigsToRemote = async (applicationId: string): Promise<void> => {
@ -129,5 +175,6 @@ export const createProtectedAppLibrary = (queries: Queries) => {
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
getDefaultDomain,
addDomainToRemote,
};
};

View file

@ -0,0 +1,34 @@
{
"paths": {
"/api/applications/{id}/protected-app-metadata/custom-domains": {
"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.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"domain": {
"description": "The domain to be added to the application."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The domain has been added to the application."
},
"409": {
"description": "The domain already exists."
},
"422": {
"description": "Exeeded the maximum number of domains allowed or the domain is invalid."
}
}
}
}
}
}

View file

@ -0,0 +1,90 @@
import { DomainStatus } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockProtectedApplication } from '#src/__mocks__/index.js';
import { mockIdGenerators } from '#src/test-utils/nanoid.js';
import { MockTenant } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const mockDomain = 'app.example.com';
const updateApplicationById = jest.fn();
const findApplicationById = jest.fn(async () => mockProtectedApplication);
const mockDomainResponse = {
domain: mockDomain,
cloudflareData: null,
status: DomainStatus.PendingVerification,
error: null,
dnsRecords: [
{
type: 'CNAME',
name: mockDomain,
value: 'origin',
},
],
};
const addDomainToRemote = jest.fn(async () => mockDomainResponse);
await mockIdGenerators();
const tenantContext = new MockTenant(
undefined,
{
applications: {
findApplicationById,
updateApplicationById,
},
},
undefined,
{
protectedApps: { addDomainToRemote },
applications: { validateProtectedApplicationById: jest.fn() },
}
);
const { createRequester } = await import('#src/utils/test-utils.js');
const applicationProtectedAppMetadataRoutes = await pickDefault(
import('./application-protected-app-metadata.js')
);
describe('application protected app metadata routes', () => {
const requester = createRequester({
authedRoutes: applicationProtectedAppMetadataRoutes,
tenantContext,
});
describe('POST /applications/:applicationId/protected-app-metadata/custom-domains', () => {
it('should return 201', async () => {
const response = await requester
.post(`/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains`)
.send({
domain: mockDomain,
});
expect(response.status).toEqual(201);
expect(updateApplicationById).toHaveBeenCalledWith(mockProtectedApplication.id, {
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [mockDomainResponse],
},
});
});
it('throw when domain exists', async () => {
findApplicationById.mockResolvedValueOnce({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [mockDomainResponse],
},
});
const response = await requester
.post(`/applications/asdf/protected-app-metadata/custom-domains`)
.send({
domain: mockDomain,
});
expect(response.status).toEqual(400);
});
});
});

View file

@ -0,0 +1,65 @@
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
export default function applicationProtectedAppMetadataRoutes<T extends AuthedRouter>(
...[
router,
{
queries: {
applications: { findApplicationById, updateApplicationById },
},
libraries: {
applications: { validateProtectedApplicationById },
protectedApps: { addDomainToRemote },
},
},
]: RouterInitArgs<T>
) {
const params = Object.freeze({ id: z.string().min(1) } as const);
const pathname = '/applications/:id/protected-app-metadata';
const customDomainsPathname = `${pathname}/custom-domains`;
// Guard application exists and is a protected app
router.use(pathname, koaGuard({ params: z.object(params) }), async (ctx, next) => {
const { id } = ctx.guard.params;
await validateProtectedApplicationById(id);
return next();
});
router.post(
customDomainsPathname,
koaGuard({
params: z.object(params),
body: z.object({ domain: z.string() }),
status: [201, 404, 422],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { domain } = ctx.guard.body;
const { protectedAppMetadata } = await findApplicationById(id);
assertThat(protectedAppMetadata, 'application.protected_app_not_configured');
assertThat(
!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0,
'domain.limit_to_one_domain'
);
// TODO: LOG-8066 check if domain is already in use
const customDomain = await addDomainToRemote(domain);
await updateApplicationById(id, {
protectedAppMetadata: { ...protectedAppMetadata, customDomains: [customDomain] },
});
ctx.status = 201;
return next();
}
);
}

View file

@ -124,7 +124,7 @@ describe('application route', () => {
type,
protectedAppMetadata: {
subDomain: 'mock',
origin: protectedAppMetadata?.origin,
origin: protectedAppMetadata.origin,
},
});
expect(response.status).toEqual(200);
@ -136,8 +136,8 @@ describe('application route', () => {
type,
protectedAppMetadata,
oidcClientMetadata: {
redirectUris: [`https://${protectedAppMetadata?.host ?? ''}/callback`],
postLogoutRedirectUris: [`https://${protectedAppMetadata?.host ?? ''}`],
redirectUris: [`https://${protectedAppMetadata.host}/callback`],
postLogoutRedirectUris: [`https://${protectedAppMetadata.host}`],
},
});
});
@ -329,7 +329,7 @@ describe('application route', () => {
204
);
expect(deleteRemoteAppConfigs).toHaveBeenCalledWith(
mockProtectedApplication.protectedAppMetadata?.host
mockProtectedApplication.protectedAppMetadata.host
);
});

View file

@ -11,6 +11,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoutes from './admin-user/index.js';
import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js';
import applicationRoleRoutes from './applications/application-role.js';
import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js';
import applicationUserConsentOrganizationRoutes from './applications/application-user-consent-organization.js';
@ -54,6 +55,7 @@ const createRouters = (tenant: TenantContext) => {
applicationUserConsentScopeRoutes(managementRouter, tenant);
applicationSignInExperienceRoutes(managementRouter, tenant);
applicationUserConsentOrganizationRoutes(managementRouter, tenant);
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
}
logtoConfigRoutes(managementRouter, tenant);