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:
parent
5625b8838c
commit
fb65c65893
4 changed files with 137 additions and 36 deletions
|
@ -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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue