0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core,schemas): cors allowed origins (#507)

* feat(schemas): cors allowed origins of application in custom OIDC client metadata

* refactor(schemas): rename CustomClientMetadataType to CustomClientMetadataKey

* feat(core): cors allowed origins
This commit is contained in:
IceHe.xyz 2022-04-08 18:16:20 +08:00 committed by GitHub
parent 5625b8838c
commit fb65c65893
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 36 deletions

View file

@ -1,12 +1,13 @@
/* istanbul ignore file */
import { customClientMetadataGuard, CustomClientMetadataType } from '@logto/schemas';
import { CustomClientMetadataKey } from '@logto/schemas';
import { fromKeyLike } from 'jose/jwk/from_key_like';
import Koa from 'koa';
import mount from 'koa-mount';
import { Provider, errors } from 'oidc-provider';
import postgresAdapter from '@/oidc/adapter';
import { isOriginAllowed, validateCustomClientMetadata } from '@/oidc/utils';
import { findResourceByIndicator } from '@/queries/resource';
import { findUserById } from '@/queries/user';
import { routes } from '@/routes/consts';
@ -76,22 +77,13 @@ export default async function initOidc(app: Koa): Promise<Provider> {
},
},
extraClientMetadata: {
properties: Object.keys(CustomClientMetadataType),
validator: (_ctx, key, value) => {
const result = customClientMetadataGuard.pick({ [key]: true }).safeParse({ key: value });
if (!result.success) {
throw new errors.InvalidClientMetadata(key);
}
properties: Object.keys(CustomClientMetadataKey),
validator: (_, key, value) => {
validateCustomClientMetadata(key, value);
},
},
clientBasedCORS: (_, origin) => {
console.log('origin', origin);
return ['http://localhost:3001', 'https://logto.dev'].some((value) =>
origin.startsWith(value)
);
},
// https://github.com/panva/node-oidc-provider/blob/main/recipes/client_based_origins.md
clientBasedCORS: (_, origin, client) => isOriginAllowed(origin, client.metadata()),
findAccount: async (ctx, sub) => {
await findUserById(sub);

View file

@ -1,21 +1,91 @@
import { ApplicationType } from '@logto/schemas';
import { ApplicationType, CustomClientMetadataKey } from '@logto/schemas';
import { getApplicationTypeString, buildOidcClientMetadata } from './utils';
import {
isOriginAllowed,
buildOidcClientMetadata,
getApplicationTypeString,
validateCustomClientMetadata,
} from './utils';
describe('oidc utils method', () => {
it('getApplicationTypeString', () => {
expect(getApplicationTypeString(ApplicationType.SPA)).toEqual('web');
expect(getApplicationTypeString(ApplicationType.Native)).toEqual('native');
expect(getApplicationTypeString(ApplicationType.Traditional)).toEqual('web');
it('getApplicationTypeString', () => {
expect(getApplicationTypeString(ApplicationType.SPA)).toEqual('web');
expect(getApplicationTypeString(ApplicationType.Native)).toEqual('native');
expect(getApplicationTypeString(ApplicationType.Traditional)).toEqual('web');
});
it('buildOidcClientMetadata', () => {
const metadata = {
redirectUris: ['logto.dev'],
postLogoutRedirectUris: ['logto.dev'],
logoUri: 'logto.pnf',
};
expect(buildOidcClientMetadata()).toEqual({ redirectUris: [], postLogoutRedirectUris: [] });
expect(buildOidcClientMetadata(metadata)).toEqual(metadata);
});
describe('validateMetadata', () => {
describe('corsAllowedOrigins', () => {
it('should not throw when corsAllowedOrigins is empty', () => {
expect(() => {
validateCustomClientMetadata('corsAllowedOrigins', []);
}).not.toThrow();
});
it('should not throw when corsAllowedOrigins are all valid', () => {
expect(() => {
validateCustomClientMetadata('corsAllowedOrigins', [
'http://localhost:3001',
'https://logto.dev',
]);
}).not.toThrow();
});
it('should throw when corsAllowedOrigins are not all valid', () => {
expect(() => {
validateCustomClientMetadata('corsAllowedOrigins', ['', 'logto.dev']);
}).toThrow();
});
});
it('buildOidcClientMetadata', () => {
const metadata = {
redirectUris: ['logto.dev'],
postLogoutRedirectUris: ['logto.dev'],
logoUri: 'logto.pnf',
};
expect(buildOidcClientMetadata()).toEqual({ redirectUris: [], postLogoutRedirectUris: [] });
expect(buildOidcClientMetadata(metadata)).toEqual(metadata);
describe.each(['idTokenTtl', 'refreshTokenTtl'])('%s', (ttlKey) => {
test(`${ttlKey} should not throw when it is a number`, () => {
expect(() => {
validateCustomClientMetadata(ttlKey, 5000);
}).not.toThrow();
});
test(`${ttlKey} should throw when it is not a number`, () => {
expect(() => {
validateCustomClientMetadata(ttlKey, 'string_value');
}).toThrow();
});
});
});
describe('corsAllowOrigin', () => {
it('should return false if there is no corsAllowOrigins', () => {
expect(isOriginAllowed('https://logto.dev', {})).toBeFalsy();
});
it('should return false if corsAllowOrigins is empty', () => {
expect(
isOriginAllowed('https://logto.dev', { [CustomClientMetadataKey.CorsAllowedOrigins]: [] })
).toBeFalsy();
});
it('should return false if corsAllowOrigins do not include the origin', () => {
expect(
isOriginAllowed('http://localhost:3001', {
[CustomClientMetadataKey.CorsAllowedOrigins]: ['https://logto.dev'],
})
).toBeFalsy();
});
it('should return true if corsAllowOrigins include the origin', () => {
expect(
isOriginAllowed('https://logto.dev', {
[CustomClientMetadataKey.CorsAllowedOrigins]: ['https://logto.dev'],
})
).toBeTruthy();
});
});

View file

@ -1,4 +1,11 @@
import { ApplicationType, OidcClientMetadata } from '@logto/schemas';
import {
ApplicationType,
CustomClientMetadata,
customClientMetadataGuard,
CustomClientMetadataKey,
OidcClientMetadata,
} from '@logto/schemas';
import { errors } from 'oidc-provider';
export const getApplicationTypeString = (type: ApplicationType) =>
type === ApplicationType.Native ? 'native' : 'web';
@ -8,3 +15,33 @@ export const buildOidcClientMetadata = (metadata?: OidcClientMetadata): OidcClie
postLogoutRedirectUris: [],
...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) => {
const result = customClientMetadataGuard.pick({ [key]: true }).safeParse({ [key]: value });
if (!result.success) {
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) =>
Boolean(customClientMetadata.corsAllowedOrigins?.includes(origin));

View file

@ -28,14 +28,16 @@ export const oidcClientMetadataGuard = z.object({
export type OidcClientMetadata = z.infer<typeof oidcClientMetadataGuard>;
export enum CustomClientMetadataType {
idTokenTtl = 'idTokenTtl',
refreshTokenTtl = 'refreshTokenTtl',
export enum CustomClientMetadataKey {
CorsAllowedOrigins = 'corsAllowedOrigins',
IdTokenTtl = 'idTokenTtl',
RefreshTokenTtl = 'refreshTokenTtl',
}
export const customClientMetadataGuard = z.object({
[CustomClientMetadataType.idTokenTtl]: z.number().optional(),
[CustomClientMetadataType.refreshTokenTtl]: z.number().optional(),
[CustomClientMetadataKey.CorsAllowedOrigins]: z.string().array().optional(),
[CustomClientMetadataKey.IdTokenTtl]: z.number().optional(),
[CustomClientMetadataKey.RefreshTokenTtl]: z.number().optional(),
});
export type CustomClientMetadata = z.infer<typeof customClientMetadataGuard>;