0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core,shared,phrases): check subdomain when creating protected app (#5217)

This commit is contained in:
wangsijie 2024-01-16 16:30:03 +08:00 committed by GitHub
parent 400ee914d6
commit e1bbbd9ebf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 285 additions and 39 deletions

View file

@ -51,8 +51,8 @@ export const mockProtectedApplication: Application = {
type: ApplicationType.Protected,
description: null,
oidcClientMetadata: {
redirectUris: [],
postLogoutRedirectUris: [],
redirectUris: ['https://mock.protected.dev/callback'],
postLogoutRedirectUris: ['https://mock.protected.dev'],
},
customClientMetadata: {
corsAllowedOrigins: ['http://localhost:3000', 'http://localhost:3001', 'https://logto.dev'],

View file

@ -1,6 +1,11 @@
import { createMockUtils } from '@logto/shared/esm';
import { mockProtectedApplication } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import {
defaultProtectedAppPageRules,
defaultProtectedAppSessionDuration,
} from '#src/routes/applications/constants.js';
import SystemContext from '#src/tenants/SystemContext.js';
const { jest } = import.meta;
@ -17,8 +22,9 @@ const { MockQueries } = await import('#src/test-utils/tenant.js');
const { createProtectedAppLibrary } = await import('./protected-app.js');
const findApplicationById = jest.fn(async () => mockProtectedApplication);
const { syncAppConfigsToRemote } = createProtectedAppLibrary(
new MockQueries({ applications: { findApplicationById } })
const findApplicationByProtectedAppHost = jest.fn();
const { syncAppConfigsToRemote, checkAndBuildProtectedAppData } = createProtectedAppLibrary(
new MockQueries({ applications: { findApplicationById, findApplicationByProtectedAppHost } })
);
const protectedAppConfigProviderConfig = {
@ -26,6 +32,7 @@ const protectedAppConfigProviderConfig = {
namespaceIdentifier: 'fake_namespace_id',
keyName: 'fake_key_name',
apiToken: '',
domain: 'protected.app',
};
beforeAll(() => {
@ -72,3 +79,43 @@ describe('syncAppConfigsToRemote()', () => {
);
});
});
describe('checkAndBuildProtectedAppData()', () => {
const origin = 'https://example.com';
it('should throw if subdomain is invalid', async () => {
await expect(checkAndBuildProtectedAppData({ subDomain: 'a-', origin })).rejects.toThrowError(
new RequestError({
code: 'application.invalid_subdomain',
status: 422,
})
);
});
it('should throw if subdomain is not available', async () => {
findApplicationByProtectedAppHost.mockResolvedValueOnce(mockProtectedApplication);
await expect(checkAndBuildProtectedAppData({ subDomain: 'a', origin })).rejects.toThrowError(
new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
})
);
});
it('should return data if subdomain is available', async () => {
const subDomain = 'a';
const host = `${subDomain}.${protectedAppConfigProviderConfig.domain}`;
await expect(checkAndBuildProtectedAppData({ subDomain, origin })).resolves.toEqual({
protectedAppMetadata: {
host,
origin,
sessionDuration: defaultProtectedAppSessionDuration,
pageRules: defaultProtectedAppPageRules,
},
oidcClientMetadata: {
redirectUris: [`https://${host}/callback`],
postLogoutRedirectUris: [`https://${host}`],
},
});
});
});

View file

@ -1,4 +1,12 @@
import { type Application } from '@logto/schemas';
import { isValidSubdomain } from '@logto/shared';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import {
defaultProtectedAppPageRules,
defaultProtectedAppSessionDuration,
} from '#src/routes/applications/constants.js';
import type Queries from '#src/tenants/Queries.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
@ -28,7 +36,7 @@ const deleteRemoteAppConfigs = async (host: string): Promise<void> => {
export const createProtectedAppLibrary = (queries: Queries) => {
const {
applications: { findApplicationById },
applications: { findApplicationById, findApplicationByProtectedAppHost },
} = queries;
const syncAppConfigsToRemote = async (applicationId: string): Promise<void> => {
@ -58,8 +66,59 @@ export const createProtectedAppLibrary = (queries: Queries) => {
);
};
/**
* Build application data for protected app
* check if subdomain is valid
* generate host based on subdomain
* generate default protectedAppMetadata based on host and origin
* generate redirectUris and postLogoutRedirectUris based on host
*/
const checkAndBuildProtectedAppData = async ({
subDomain,
origin,
}: {
subDomain: string;
origin: string;
}): Promise<Pick<Application, 'protectedAppMetadata' | 'oidcClientMetadata'>> => {
assertThat(
isValidSubdomain(subDomain),
new RequestError({
code: 'application.invalid_subdomain',
status: 422,
})
);
// Skip for integration test, use empty value instead
const { domain } = EnvSet.values.isIntegrationTest ? { domain: '' } : await getProviderConfig();
const host = `${subDomain}.${domain}`;
const application = await findApplicationByProtectedAppHost(host);
assertThat(
!application,
new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
})
);
return {
protectedAppMetadata: {
host,
origin,
sessionDuration: defaultProtectedAppSessionDuration,
pageRules: defaultProtectedAppPageRules,
},
oidcClientMetadata: {
redirectUris: [`https://${host}/callback`],
postLogoutRedirectUris: [`https://${host}`],
},
};
};
return {
syncAppConfigsToRemote,
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
};
};

View file

@ -1,4 +1,4 @@
import { Applications } from '@logto/schemas';
import { ApplicationType, Applications } from '@logto/schemas';
import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import { snakeCase } from 'snake-case';
@ -22,6 +22,7 @@ const { createApplicationQueries } = await import('./application.js');
const {
findTotalNumberOfApplications,
findApplicationById,
findApplicationByProtectedAppHost,
insertApplication,
updateApplicationById,
deleteApplicationById,
@ -66,6 +67,27 @@ describe('application query', () => {
await findApplicationById(id);
});
it('findApplicationByProtectedAppHost', async () => {
const host = 'host.protected.app';
const rowData = { host };
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.protectedAppMetadata}->>'host' = $1
and ${fields.type} = $2
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([host, ApplicationType.Protected]);
return createMockQueryResult([rowData]);
});
await findApplicationByProtectedAppHost(host);
});
it('insertApplication', async () => {
const keys = excludeAutoSetFields(Applications.fieldKeys);

View file

@ -130,6 +130,14 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
const findApplicationByProtectedAppHost = async (host: string) =>
pool.maybeOne<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.protectedAppMetadata}->>'host' = ${host}
and ${fields.type} = ${ApplicationType.Protected}
`);
const insertApplication = buildInsertIntoWithPool(pool)(Applications, {
returning: true,
});
@ -231,6 +239,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
findApplications,
findTotalNumberOfApplications,
findApplicationById,
findApplicationByProtectedAppHost,
insertApplication,
updateApplication,
updateApplicationById,

View file

@ -13,6 +13,11 @@ const findApplicationById = jest.fn(async () => mockApplication);
const deleteApplicationById = jest.fn();
const syncAppConfigsToRemote = jest.fn();
const deleteRemoteAppConfigs = jest.fn();
const checkAndBuildProtectedAppData = jest.fn(async () => {
const { oidcClientMetadata, protectedAppMetadata } = mockProtectedApplication;
return { oidcClientMetadata, protectedAppMetadata };
});
const updateApplicationById = jest.fn(
async (_, data: Partial<CreateApplication>): Promise<Application> => ({
...mockApplication,
@ -46,7 +51,11 @@ const tenantContext = new MockTenant(
undefined,
{
quota: createMockQuotaLibrary(),
protectedApps: { syncAppConfigsToRemote, deleteRemoteAppConfigs },
protectedApps: {
syncAppConfigsToRemote,
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
},
}
);
@ -109,7 +118,7 @@ describe('application route', () => {
name,
type,
protectedAppMetadata: {
host: protectedAppMetadata?.host,
subDomain: 'mock',
origin: protectedAppMetadata?.origin,
},
});

View file

@ -24,7 +24,6 @@ import {
applicationCreateGuard,
applicationPatchGuard,
} from './types.js';
import { buildProtectedAppData } from './utils.js';
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
roles.some(({ role: { name } }) => name === InternalRole.Admin);
@ -152,8 +151,6 @@ export default function applicationRoutes<T extends AuthedRouter>(
);
}
// TODO LOG-7794: check and add domain to Cloudflare
const application = await insertApplication({
id: generateStandardId(),
secret: generateStandardSecret(),
@ -161,7 +158,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
...conditional(
rest.type === ApplicationType.Protected &&
protectedAppMetadata &&
buildProtectedAppData(protectedAppMetadata)
(await protectedApps.checkAndBuildProtectedAppData(protectedAppMetadata))
),
...rest,
});

View file

@ -31,7 +31,7 @@ const applicationCreateGuardWithProtectedAppMetadata = originalApplicationCreate
.extend({
protectedAppMetadata: z
.object({
host: z.string(),
subDomain: z.string(),
origin: z.string(),
})
.optional(),

View file

@ -1,19 +0,0 @@
import { defaultProtectedAppPageRules, defaultProtectedAppSessionDuration } from './constants.js';
/**
* Build application data for protected app
* generate default protectedAppMetadata based on host and origin
* generate redirectUris and postLogoutRedirectUris based on host
*/
export const buildProtectedAppData = ({ host, origin }: { host: string; origin: string }) => ({
protectedAppMetadata: {
host,
origin,
sessionDuration: defaultProtectedAppSessionDuration,
pageRules: defaultProtectedAppPageRules,
},
oidcClientMetadata: {
redirectUris: [`https://${host}/callback`],
postLogoutRedirectUris: [`https://${host}`],
},
});

View file

@ -45,10 +45,7 @@ export const updateProtectedAppSiteConfigs = async (
Authorization: `Bearer ${auth.apiToken}`,
},
throwHttpErrors: false,
json: {
metadata: {},
value: JSON.stringify(value),
},
json: value,
}
);

View file

@ -52,7 +52,7 @@ describe('admin console application', () => {
const applicationName = 'test-protected-app';
const metadata = {
origin: 'https://example.com',
host: 'example.protected.app',
subDomain: 'example',
};
const application = await createApplication(applicationName, ApplicationType.Protected, {
@ -63,11 +63,35 @@ describe('admin console application', () => {
expect(application.name).toBe(applicationName);
expect(application.type).toBe(ApplicationType.Protected);
expect(application.protectedAppMetadata).toHaveProperty('origin', metadata.origin);
expect(application.protectedAppMetadata).toHaveProperty('host', metadata.host);
expect(application.protectedAppMetadata?.host).toContain(metadata.subDomain);
expect(application.protectedAppMetadata).toHaveProperty('sessionDuration');
await deleteApplication(application.id);
});
it('should throw error when creating protected application with existing subdomain', async () => {
const applicationName = 'test-protected-app';
const metadata = {
origin: 'https://example.com',
subDomain: 'example',
};
const application = await createApplication(applicationName, ApplicationType.Protected, {
// @ts-expect-error the create guard has been modified
protectedAppMetadata: metadata,
});
await expectRejects(
createApplication('test-create-app', ApplicationType.Protected, {
// @ts-expect-error the create guard has been modified
protectedAppMetadata: metadata,
}),
{
code: 'application.protected_application_subdomain_exists',
statusCode: 422,
}
);
await deleteApplication(application.id);
});
it('should throw error when creating a protected application with invalid type', async () => {
await expectRejects(createApplication('test-create-app', ApplicationType.Protected), {
code: 'application.protected_app_metadata_is_required',
@ -105,7 +129,7 @@ describe('admin console application', () => {
it('should update application details for protected app successfully', async () => {
const metadata = {
origin: 'https://example.com',
host: 'example.protected.app',
subDomain: 'example',
};
const application = await createApplication('test-update-app', ApplicationType.Protected, {

View file

@ -16,6 +16,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -11,6 +11,9 @@ const application = {
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
protected_application_only: 'The feature is only available for protected applications.',
protected_application_misconfigured: 'Protected application is misconfigured.',
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -16,6 +16,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -17,6 +17,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -17,6 +17,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -16,6 +16,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -15,6 +15,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -15,6 +15,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -16,6 +16,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -16,6 +16,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -17,6 +17,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -15,6 +15,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -14,6 +14,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -14,6 +14,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -14,6 +14,11 @@ const application = {
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
/** UNTRANSLATED */
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
};
export default Object.freeze(application);

View file

@ -158,6 +158,8 @@ export const protectedAppConfigProviderDataGuard = z.object({
namespaceIdentifier: z.string(),
/* Key prefix for protected app config */
keyName: z.string(),
/* The default domain (e.g protected.app) for the protected app */
domain: z.string(),
apiToken: z.string(), // Requires account permission for "KV Storage Edit"
});

View file

@ -3,3 +3,4 @@ export * from './ttl-cache.js';
export * from './id.js';
export * from './user-display-name.js';
export * from './phone.js';
export * from './sub-domain.js';

View file

@ -0,0 +1,18 @@
import { isValidSubdomain } from './sub-domain.js';
describe('isValidSubdomain()', () => {
it('should return true for valid subdomains', () => {
expect(isValidSubdomain('a')).toBe(true);
expect(isValidSubdomain('1')).toBe(true);
expect(isValidSubdomain('a1')).toBe(true);
expect(isValidSubdomain('a1-b2')).toBe(true);
});
it('should return false for invalid subdomains', () => {
expect(isValidSubdomain('')).toBe(false);
expect(isValidSubdomain('a1-')).toBe(false);
expect(isValidSubdomain('-a1')).toBe(false);
expect(isValidSubdomain('a1-')).toBe(false);
expect(isValidSubdomain('a1.b')).toBe(false);
});
});

View file

@ -0,0 +1,7 @@
/**
* Checks if the subdomain string is valid
* @param subdomain subdomain string
* @returns boolean indicating whether the subdomain is valid
*/
export const isValidSubdomain = (subdomain: string): boolean =>
/^([\da-z]([\da-z-]{0,61}[\da-z])?)$/i.test(subdomain);