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:
parent
ea407f19fa
commit
ef29f490af
9 changed files with 265 additions and 9 deletions
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue