mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core): validate custom client metadata when post or patch application (#529)
This commit is contained in:
parent
ee84462e14
commit
c3c2bf20f1
6 changed files with 44 additions and 46 deletions
|
@ -24,7 +24,11 @@ export const mockApplication: Application = {
|
||||||
redirectUris: [],
|
redirectUris: [],
|
||||||
postLogoutRedirectUris: [],
|
postLogoutRedirectUris: [],
|
||||||
},
|
},
|
||||||
customClientMetadata: {},
|
customClientMetadata: {
|
||||||
|
corsAllowedOrigins: ['http://localhost:3000', 'http://localhost:3001', 'https://logto.dev'],
|
||||||
|
idTokenTtl: 5000,
|
||||||
|
refreshTokenTtl: 6_000_000,
|
||||||
|
},
|
||||||
createdAt: 1_645_334_775_356,
|
createdAt: 1_645_334_775_356,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@ describe('validateMetadata', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('corsAllowOrigin', () => {
|
describe('isOriginAllowed', () => {
|
||||||
it('should return false if there is no corsAllowOrigins', () => {
|
it('should return false if there is no corsAllowOrigins', () => {
|
||||||
expect(isOriginAllowed('https://logto.dev', {})).toBeFalsy();
|
expect(isOriginAllowed('https://logto.dev', {})).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,6 @@ import {
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
CustomClientMetadata,
|
CustomClientMetadata,
|
||||||
customClientMetadataGuard,
|
customClientMetadataGuard,
|
||||||
CustomClientMetadataKey,
|
|
||||||
OidcClientMetadata,
|
OidcClientMetadata,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
|
@ -16,31 +15,12 @@ export const buildOidcClientMetadata = (metadata?: OidcClientMetadata): OidcClie
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOrigin = (value: string) => {
|
|
||||||
try {
|
|
||||||
const { origin } = new URL(value);
|
|
||||||
|
|
||||||
// Origin: <scheme> "://" <hostname> [ ":" <port> ]
|
|
||||||
return value === origin;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validateCustomClientMetadata = (key: string, value: unknown) => {
|
export const validateCustomClientMetadata = (key: string, value: unknown) => {
|
||||||
const result = customClientMetadataGuard.pick({ [key]: true }).safeParse({ [key]: value });
|
const result = customClientMetadataGuard.pick({ [key]: true }).safeParse({ [key]: value });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new errors.InvalidClientMetadata(key);
|
throw new errors.InvalidClientMetadata(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
key === CustomClientMetadataKey.CorsAllowedOrigins &&
|
|
||||||
Array.isArray(value) &&
|
|
||||||
value.some((origin) => !isOrigin(origin))
|
|
||||||
) {
|
|
||||||
throw new errors.InvalidClientMetadata(CustomClientMetadataKey.CorsAllowedOrigins);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOriginAllowed = (origin: string, customClientMetadata: CustomClientMetadata) =>
|
export const isOriginAllowed = (origin: string, customClientMetadata: CustomClientMetadata) =>
|
||||||
|
|
|
@ -34,6 +34,12 @@ jest.mock('@/utils/id', () => ({
|
||||||
buildIdGenerator: jest.fn(() => () => 'randomId'),
|
buildIdGenerator: jest.fn(() => () => 'randomId'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const customClientMetadata = {
|
||||||
|
corsAllowedOrigins: ['http://localhost:5000', 'http://localhost:5001', 'https://silverhand.com'],
|
||||||
|
idTokenTtl: 999_999,
|
||||||
|
refreshTokenTtl: 100_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
describe('application route', () => {
|
describe('application route', () => {
|
||||||
const applicationRequest = createRequester({ authedRoutes: applicationRoutes });
|
const applicationRequest = createRequester({ authedRoutes: applicationRoutes });
|
||||||
|
|
||||||
|
@ -47,13 +53,10 @@ describe('application route', () => {
|
||||||
it('POST /applications', async () => {
|
it('POST /applications', async () => {
|
||||||
const name = 'FooApplication';
|
const name = 'FooApplication';
|
||||||
const type = ApplicationType.Traditional;
|
const type = ApplicationType.Traditional;
|
||||||
const description = 'New created application';
|
|
||||||
|
|
||||||
const response = await applicationRequest.post('/applications').send({
|
const response = await applicationRequest
|
||||||
name,
|
.post('/applications')
|
||||||
type,
|
.send({ name, type, customClientMetadata });
|
||||||
description,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
|
@ -61,24 +64,33 @@ describe('application route', () => {
|
||||||
id: 'randomId',
|
id: 'randomId',
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
description,
|
customClientMetadata,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /applications should throw with invalid input body', async () => {
|
it('POST /applications should throw with invalid input body', async () => {
|
||||||
const name = 'FooApplication';
|
const name = 'FooApplication';
|
||||||
const type = ApplicationType.Traditional;
|
const type = ApplicationType.Traditional;
|
||||||
const description = 'New created application';
|
|
||||||
|
|
||||||
await expect(applicationRequest.post('/applications')).resolves.toHaveProperty('status', 400);
|
await expect(applicationRequest.post('/applications')).resolves.toHaveProperty('status', 400);
|
||||||
await expect(
|
await expect(
|
||||||
applicationRequest.post('/applications').send({ description })
|
applicationRequest.post('/applications').send({ customClientMetadata })
|
||||||
).resolves.toHaveProperty('status', 400);
|
).resolves.toHaveProperty('status', 400);
|
||||||
await expect(
|
await expect(
|
||||||
applicationRequest.post('/applications').send({ name, description })
|
applicationRequest.post('/applications').send({ name, customClientMetadata })
|
||||||
).resolves.toHaveProperty('status', 400);
|
).resolves.toHaveProperty('status', 400);
|
||||||
await expect(
|
await expect(
|
||||||
applicationRequest.post('/applications').send({ type, description })
|
applicationRequest.post('/applications').send({ type, customClientMetadata })
|
||||||
|
).resolves.toHaveProperty('status', 400);
|
||||||
|
await expect(
|
||||||
|
applicationRequest.post('/applications').send({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
customClientMetadata: {
|
||||||
|
...customClientMetadata,
|
||||||
|
corsAllowedOrigins: [''],
|
||||||
|
},
|
||||||
|
})
|
||||||
).resolves.toHaveProperty('status', 400);
|
).resolves.toHaveProperty('status', 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -91,25 +103,27 @@ describe('application route', () => {
|
||||||
|
|
||||||
it('PATCH /applications/:applicationId', async () => {
|
it('PATCH /applications/:applicationId', async () => {
|
||||||
const name = 'FooApplication';
|
const name = 'FooApplication';
|
||||||
const description = 'New created application';
|
|
||||||
|
|
||||||
const response = await applicationRequest.patch('/applications/foo').send({
|
const response = await applicationRequest
|
||||||
name,
|
.patch('/applications/foo')
|
||||||
description,
|
.send({ name, customClientMetadata });
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({ ...mockApplication, name, customClientMetadata });
|
||||||
...mockApplication,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PATCH /applications/:applicationId expect to throw with invalid properties', async () => {
|
it('PATCH /applications/:applicationId expect to throw with invalid properties', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
applicationRequest.patch('/applications/doo').send({ type: 'node' })
|
applicationRequest.patch('/applications/doo').send({ type: 'node' })
|
||||||
).resolves.toHaveProperty('status', 400);
|
).resolves.toHaveProperty('status', 400);
|
||||||
|
await expect(
|
||||||
|
applicationRequest.patch('/applications/doo').send({
|
||||||
|
customClientMetadata: {
|
||||||
|
...customClientMetadata,
|
||||||
|
corsAllowedOrigins: [''],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toHaveProperty('status', 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /applications/:applicationId', async () => {
|
it('DELETE /applications/:applicationId', async () => {
|
||||||
|
|
|
@ -43,14 +43,14 @@ export default function applicationRoutes<T extends AuthedRouter>(router: T) {
|
||||||
.merge(Applications.createGuard.pick({ name: true, type: true })),
|
.merge(Applications.createGuard.pick({ name: true, type: true })),
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { name, type, oidcClientMetadata, ...rest } = ctx.guard.body;
|
const { name, type, oidcClientMetadata, customClientMetadata } = ctx.guard.body;
|
||||||
|
|
||||||
ctx.body = await insertApplication({
|
ctx.body = await insertApplication({
|
||||||
id: applicationId(),
|
id: applicationId(),
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
|
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
|
||||||
...rest,
|
customClientMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|
|
@ -35,7 +35,7 @@ export enum CustomClientMetadataKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customClientMetadataGuard = z.object({
|
export const customClientMetadataGuard = z.object({
|
||||||
[CustomClientMetadataKey.CorsAllowedOrigins]: z.string().array().optional(),
|
[CustomClientMetadataKey.CorsAllowedOrigins]: z.string().url().array().optional(),
|
||||||
[CustomClientMetadataKey.IdTokenTtl]: z.number().optional(),
|
[CustomClientMetadataKey.IdTokenTtl]: z.number().optional(),
|
||||||
[CustomClientMetadataKey.RefreshTokenTtl]: z.number().optional(),
|
[CustomClientMetadataKey.RefreshTokenTtl]: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue