diff --git a/packages/uma/config/default.json b/packages/uma/config/default.json index c41474f..07934b3 100644 --- a/packages/uma/config/default.json +++ b/packages/uma/config/default.json @@ -33,26 +33,30 @@ "@id": "urn:uma:default:NodeHttpRequestResponseHandler", "@type": "NodeHttpRequestResponseHandler", "httpHandler": { - "@id": "urn:uma:default:RoutedHttpRequestHandler", - "@type": "RoutedHttpRequestHandler", - "handlerControllerList": [ - { - "@id": "urn:uma:default:HttpHandlerController", - "@type": "HttpHandlerController", - "label": "ControllerList", - "routes": [ - { "@id": "urn:uma:default:UmaConfigRoute" }, - { "@id": "urn:uma:default:JwksRoute" }, - { "@id": "urn:uma:default:TokenRoute" }, - { "@id": "urn:uma:default:PermissionRegistrationRoute" }, - { "@id": "urn:uma:default:ResourceRegistrationRoute" }, - { "@id": "urn:uma:default:ResourceRegistrationOpsRoute" }, - { "@id": "urn:uma:default:IntrospectionRoute" } - ] + "@id": "urn:uma:default:CorsRequestHandler", + "@type": "CorsRequestHandler", + "handler": { + "@id": "urn:uma:default:RoutedHttpRequestHandler", + "@type": "RoutedHttpRequestHandler", + "handlerControllerList": [ + { + "@id": "urn:uma:default:HttpHandlerController", + "@type": "HttpHandlerController", + "label": "ControllerList", + "routes": [ + { "@id": "urn:uma:default:UmaConfigRoute" }, + { "@id": "urn:uma:default:JwksRoute" }, + { "@id": "urn:uma:default:TokenRoute" }, + { "@id": "urn:uma:default:PermissionRegistrationRoute" }, + { "@id": "urn:uma:default:ResourceRegistrationRoute" }, + { "@id": "urn:uma:default:ResourceRegistrationOpsRoute" }, + { "@id": "urn:uma:default:IntrospectionRoute" } + ] + } + ], + "defaultHandler": { + "@type": "DefaultRequestHandler" } - ], - "defaultHandler": { - "@type": "DefaultRequestHandler" } } } @@ -61,4 +65,4 @@ "comment": "Configuration for the UMA AS." } ] -} \ No newline at end of file +} diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts index 43fce3b..e9d376a 100644 --- a/packages/uma/src/index.ts +++ b/packages/uma/src/index.ts @@ -83,6 +83,7 @@ export * from './util/http/models/HttpHandlerResponse'; export * from './util/http/models/HttpHandlerRoute'; export * from './util/http/models/HttpMethod'; export * from './util/http/server/ErrorHandler'; +export * from './util/http/server/CorsRequestHandler'; export * from './util/http/server/NodeHttpRequestResponseHandler'; export * from './util/http/server/NodeHttpServer'; export * from './util/http/server/NodeHttpStreamsHandler'; diff --git a/packages/uma/src/util/http/server/CorsRequestHandler.ts b/packages/uma/src/util/http/server/CorsRequestHandler.ts new file mode 100644 index 0000000..279bb69 --- /dev/null +++ b/packages/uma/src/util/http/server/CorsRequestHandler.ts @@ -0,0 +1,137 @@ +import { getLogger } from '../../logging/LoggerUtils'; +import { HttpHandler } from '../models/HttpHandler'; +import { HttpHandlerContext } from '../models/HttpHandlerContext'; +import { HttpHandlerResponse } from '../models/HttpHandlerResponse'; + +export const cleanHeaders = (headers: Record): Record => Object.entries(headers).reduce( + (acc: Record, [ key, value ]) => { + + const lKey = key.toLowerCase(); + + return { ... acc, [lKey]: acc[lKey] ? `${acc[lKey]},${value}` : value }; + + }, {}, +); + +export interface HttpCorsOptions { + origins?: string[]; + allowMethods?: string[]; + allowHeaders?: string[]; + exposeHeaders?: string[]; + credentials?: boolean; + maxAge?: number; +} +export class CorsRequestHandler implements HttpHandler { + + public logger = getLogger(); + + constructor( + private handler: HttpHandler, + private options?: HttpCorsOptions, + private passThroughOptions: boolean = false, + ) { } + + async handle(context: HttpHandlerContext): Promise { + + const { origins, allowMethods, allowHeaders, exposeHeaders, credentials, maxAge } = this.options || ({}); + + const requestHeaders = context.request.headers; + + const cleanRequestHeaders = cleanHeaders(requestHeaders); + + const { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars -- destructuring for removal */ + ['access-control-request-method']: requestedMethod, + ['access-control-request-headers']: requestedHeaders, + ... noCorsHeaders + } = cleanRequestHeaders; + + const noCorsRequestContext = { + ... context, + request: { + ... context.request, + headers: { + ... noCorsHeaders, + }, + }, + }; + + const requestedOrigin = cleanRequestHeaders.origin ?? ''; + + const allowOrigin = origins + ? origins.includes(requestedOrigin) + ? requestedOrigin + : undefined + : credentials + ? requestedOrigin + : '*'; + + const allowHeadersOrRequested = allowHeaders?.join(',') ?? requestedHeaders; + + if (context.request.method === 'OPTIONS') { + + /* Preflight Request */ + + this.logger.debug('Processing preflight request'); + + const routeMethods = context.route?.operations.map((op) => op.method); + const allMethods = [ 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH' ]; + + const initialOptions = this.passThroughOptions + ? this.handler.handle(noCorsRequestContext) + : Promise.resolve({ status: 204, headers: {} }); + + return initialOptions + .then((response) => ({ + ... response, + headers: response.headers ? cleanHeaders(response.headers) : {}, + })) + .then((response) => ({ + ... response, + headers: { + + ... response.headers, + ... allowOrigin && ({ + ... (allowOrigin !== '*') && { + 'vary': [ ... new Set([ + ... response.headers.vary?.split(',').map((v) => v.trim().toLowerCase()) ?? [], `origin` + ]) ].join(', ') + }, + 'access-control-allow-origin': allowOrigin, + 'access-control-allow-methods': (allowMethods ?? routeMethods ?? allMethods).join(', '), + ... (allowHeadersOrRequested) && { 'access-control-allow-headers': allowHeadersOrRequested }, + ... (credentials) && { 'access-control-allow-credentials': 'true' }, + 'access-control-max-age': (maxAge ?? -1).toString(), + }), + }, + })); + + } else { + + /* CORS Request */ + + this.logger.debug('Processing CORS request'); + + return this.handler.handle(noCorsRequestContext) + .then((response) => ({ + ... response, + headers: { + ... response.headers, + ... allowOrigin && ({ + 'access-control-allow-origin': allowOrigin, + ... (allowOrigin !== '*') && { + 'vary': [ ... new Set([ + ... response.headers?.vary?.split(',').map((v) => v.trim().toLowerCase()) ?? [], `origin` + ]) ].join(', ') + }, + ... (credentials) && { 'access-control-allow-credentials': 'true' }, + ... (exposeHeaders) && { 'access-control-expose-headers': exposeHeaders.join(',') }, + }), + }, + })); + + } + + } + +}