diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index ab6769d61..f74b4d78b 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -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 { }, }, 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); diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 1b77d5e5f..a4ccea489 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -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(); }); }); diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 9663fbae8..e2310a11b 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -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: "://" [ ":" ] + 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)); diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 30918358e..5bdddb7c6 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -28,14 +28,16 @@ export const oidcClientMetadataGuard = z.object({ export type OidcClientMetadata = z.infer; -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;