UNPKG

scimmy-routers

Version:
415 lines (366 loc) 16.8 kB
import express, { Router } from 'express'; import SCIMMY from 'scimmy'; export { default as SCIMMY } from 'scimmy'; /** * SCIMMY Search Endpoint Router * @since 1.0.0 */ class Search extends Router { /** * Construct an instance of an express router with endpoints for accessing Search POST endpoint * @param {AuthenticationContext|typeof SCIMMY.Types.Resource} [Resource] - the resource type instance for which endpoints are being registered * @param {AuthenticationContext} [context] - method to invoke to evaluate context passed to SCIMMY handlers */ constructor(Resource, context = (Resource.prototype instanceof SCIMMY.Types.Resource ? undefined : Resource)) { super({mergeParams: true}); // Respond to POST requests for /.search endpoint this.post("/.search", async (req, res, next) => { try { res.status(200).send(await (new SCIMMY.Messages.SearchRequest(req.body)).apply(Resource.prototype instanceof SCIMMY.Types.Resource ? [Resource] : undefined, await context(req))); } catch (ex) { next(ex); } }); // Respond with 404 not found to all other requests for /.search endpoint this.use("/.search", (req, res, next) => { next(new SCIMMY.Types.Error(404, null, "Endpoint Not Found")); }); } } /** * SCIMMY Resource Type Instance Endpoints Router */ class Resources extends Router { /** * Construct an instance of an express router with endpoints for a given resource type instance * @param {typeof SCIMMY.Types.Resource} Resource - the resource type instance for which endpoints are being registered * @param {AuthenticationContext} [context] - method to invoke to evaluate context passed to SCIMMY handlers */ constructor(Resource, context) { super({mergeParams: true}); // Mount /.search endpoint for resource this.use(new Search(Resource, context)); this.get("/", async (req, res, next) => { try { res.send(await new Resource(req.query).read(await context(req))); } catch (ex) { next(ex); } }); this.get("/:id", async (req, res, next) => { try { res.send(await new Resource(req.params.id, req.query).read(await context(req))); } catch (ex) { next(ex); } }); this.post("/", async (req, res, next) => { try { res.status(201).send(await new Resource(req.query).write(req.body, await context(req))); } catch (ex) { next(ex); } }); this.put("/:id", async (req, res, next) => { try { res.send(await new Resource(req.params.id, req.query).write(req.body, await context(req))); } catch (ex) { next(ex); } }); this.patch("/:id", async (req, res, next) => { try { const value = await new Resource(req.params.id, req.query).patch(req.body, await context(req)); res.status(!!value ? 200 : 204).send(value); } catch (ex) { next(ex); } }); this.delete("/:id", async (req, res, next) => { try { res.status(204).send(await new Resource(req.params.id).dispose(await context(req))); } catch (ex) { next(ex); } }); } } /** * SCIMMY Schemas Endpoints Router */ class Schemas extends Router { /** * Construct an instance of an express router with endpoints for accessing Schemas */ constructor() { super({mergeParams: true}); this.get("/Schemas", async (req, res, next) => { try { res.send(await new SCIMMY.Resources.Schema(req.query).read()); } catch (ex) { next(ex); } }); this.get("/Schemas/:id", async (req, res, next) => { try { res.send(await new SCIMMY.Resources.Schema(req.params.id, req.query).read()); } catch (ex) { next(ex); } }); } } /** * SCIMMY ResourceTypes Endpoints Router */ class ResourceTypes extends Router { /** * Construct an instance of an express router with endpoints for accessing ResourceTypes */ constructor() { super({mergeParams: true}); this.get("/ResourceTypes", async (req, res, next) => { try { res.send(await new SCIMMY.Resources.ResourceType(req.query).read()); } catch (ex) { next(ex); } }); this.get("/ResourceTypes/:id", async (req, res, next) => { try { res.send(await new SCIMMY.Resources.ResourceType(req.params.id, req.query).read()); } catch (ex) { next(ex); } }); } } /** * SCIMMY ServiceProviderConfig Endpoints Router */ class ServiceProviderConfig extends Router { /** * Construct an instance of an express router with endpoints for accessing ServiceProviderConfig */ constructor() { super({mergeParams: true}); this.get("/ServiceProviderConfig", async (req, res, next) => { try { res.send(await new SCIMMY.Resources.ServiceProviderConfig(req.query).read()); } catch (ex) { next(ex); } }); } } /** * SCIMMY Bulk Endpoint Router * @since 1.0.0 */ class Bulk extends Router { /** * Construct an instance of an express router with endpoints for accessing Bulk POST endpoint * @param {AuthenticationContext} [context] - method to invoke to evaluate context passed to SCIMMY handlers */ constructor(context) { super({mergeParams: true}); // Respond to POST requests for /Bulk endpoint this.post("/Bulk", async (req, res, next) => { try { const {supported, maxPayloadSize, maxOperations} = SCIMMY.Config.get()?.bulk ?? {}; if (!supported) { next(new SCIMMY.Types.Error(501, null, "Endpoint Not Implemented")); } else if (Number(req.header("content-length")) > maxPayloadSize) { next(new SCIMMY.Types.Error(413, null, `The size of the bulk operation exceeds maxPayloadSize limit (${maxPayloadSize})`)); } else { res.status(200).send(await (new SCIMMY.Messages.BulkRequest(req.body, maxOperations)).apply(undefined, await context(req))); } } catch (ex) { next(ex); } }); } } /** * SCIMMY Me Endpoint Router * @since 1.0.0 */ class Me extends Router { /** * Construct an instance of an express router with endpoints for accessing "Me" endpoint * @param {AuthenticationHandler} handler - method to invoke to get ID of authenticated SCIM user * @param {AuthenticationContext} [context] - method to invoke to evaluate context passed to SCIMMY handlers */ constructor(handler, context) { super({mergeParams: true}); // Respond to GET requests for /Me endpoint this.get("/Me", async (req, res, next) => { try { const id = await handler(req); const isDeclared = SCIMMY.Resources.declared(SCIMMY.Resources.User); // Only get the authenticated user if Users is declared and handler returns a string const user = (isDeclared && typeof id === "string" ? await new SCIMMY.Resources.User(id).read(await context(req)) : false); // Set the actual location of the user resource, or respond with 501 not implemented if (user && user?.meta?.location) res.location(user.meta.location).send(user); else next(new SCIMMY.Types.Error(501, null, "Endpoint Not Implemented")); } catch (ex) { next(ex); } }); // Respond with 501 not implemented to all other requests for /Me endpoint this.use("/Me$", (req, res, next) => { next(new SCIMMY.Types.Error(501, null, "Endpoint Not Implemented")); }); } } // Predefined SCIM Service Provider Config authentication scheme types const authSchemeTypes = { oauth: { type: "oauth2", name: "OAuth 2.0 Authorization Framework", description: "Authentication scheme using the OAuth 2.0 Authorization Framework Standard", specUri: "https://datatracker.ietf.org/doc/html/rfc6749" }, bearer: { type: "oauthbearertoken", name: "OAuth Bearer Token", description: "Authentication scheme using the OAuth Bearer Token Standard", specUri: "https://datatracker.ietf.org/doc/html/rfc6750" }, basic: { type: "httpbasic", name: "HTTP Basic", description: "Authentication scheme using the HTTP Basic Standard", specUri: "https://datatracker.ietf.org/doc/html/rfc2617" }, digest: { type: "httpdigest", name: "HTTP Digest", description: "Authentication scheme using the HTTP Digest Standard", specUri: "https://datatracker.ietf.org/doc/html/rfc2617" } }; /** * Method invoked to authenticate a SCIM request * @callback AuthenticationHandler * @param {express.Request} req - the express request to be authenticated * @returns {String|Promise<String>} the ID of the currently authenticated user, to be consumed by the /Me endpoint * @private */ /** * Method invoked to provide authentication context to a SCIM request * @callback AuthenticationContext * @param {express.Request} req - the express request to provide authentication context for * @returns {*|Promise<*>} Any information to pass through to a Resource's handler methods * @private */ /** * Method invoked to determine a base URI for location properties in a SCIM response * @callback AuthenticationBaseUri * @param {express.Request} req - the express request to provide the base URI for * @returns {String|Promise<String>} the base URI to use for location properties in SCIM responses * @private */ /** * SCIMMY HTTP Routers Class */ class SCIMMYRouters extends Router { /** * Construct a new instance of SCIMMYRouters, validate authentication scheme, and set SCIM Service Provider Configuration * @param {Object} authScheme - details of the means of authenticating SCIM requests * @param {String} authScheme.type - SCIM service provider authentication scheme type * @param {AuthenticationHandler} authScheme.handler Method to invoke to authenticate SCIM requests * @param {AuthenticationContext} [authScheme.context] Method to invoke to evaluate context passed to SCIMMY handlers * @param {AuthenticationBaseUri} [authScheme.baseUri] Method to invoke to determine the URL to use as the base URI for any location properties in responses * @param {String} [authScheme.docUri] URL to use as documentation URI for service provider authentication scheme */ constructor(authScheme = {}) { const {type, docUri, handler, context = (() => {}), baseUri = (() => {})} = authScheme; super({mergeParams: true}); // Make sure supplied authentication scheme is valid if (type === undefined) throw new TypeError("Missing required parameter 'type' from authentication scheme in SCIMMYRouters constructor"); if (handler === undefined) throw new TypeError("Missing required parameter 'handler' from authentication scheme in SCIMMYRouters constructor"); if (typeof handler !== "function") throw new TypeError("Parameter 'handler' must be of type 'function' for authentication scheme in SCIMMYRouters constructor"); if (authSchemeTypes[type] === undefined) throw new TypeError(`Unknown authentication scheme type '${type}' in SCIMMYRouters constructor`); if (typeof context !== "function") throw new TypeError("Parameter 'context' must be of type 'function' for authentication scheme in SCIMMYRouters constructor"); if (typeof baseUri !== "function") throw new TypeError("Parameter 'baseUri' must be of type 'function' for authentication scheme in SCIMMYRouters constructor"); // Register the authentication scheme, and other SCIM Service Provider Config options SCIMMY.Config.set({ patch: true, filter: true, sort: true, bulk: true, authenticationSchemes: [{...authSchemeTypes[type], documentationUri: docUri}] }); // Make sure SCIM JSON is decoded in request body this.use(express.json({type: ["application/scim+json", "application/json"], limit: SCIMMY.Config.get()?.bulk?.maxPayloadSize ?? "1mb"})); // Listen for incoming requests to determine basepath for all resource types this.use("/", async (req, res, next) => { // Set correct header for SCIM responses res.setHeader("Content-Type", "application/scim+json"); try { // Evaluate the request-based basepath location const basepath = await baseUri(req) ?? ""; // Make sure it's a valid URL string if (!basepath || typeof basepath === "string" && basepath.match(/^https?:\/\//)) { // Construct the actual basepath to use for resource locations... const location = basepath.replace(/\/$/, "") + req.baseUrl; // ...then set all resource basepaths correctly SCIMMY.Resources.Schema.basepath(location); SCIMMY.Resources.ResourceType.basepath(location); SCIMMY.Resources.ServiceProviderConfig.basepath(location); for (let Resource of Object.values(SCIMMY.Resources.declared())) Resource.basepath(location); next(); } else { next(new TypeError("Method 'baseUri' must return a URL string in SCIMMYRouters constructor")); } } catch (ex) { next(ex); } }); // Make sure requests are authenticated using supplied auth handler method this.use(async (req, res, next) => { try { // Run the handler await handler(req); next(); } catch (ex) { // Wrap exceptions in unauthorized message res.status(401).send(new SCIMMY.Messages.Error({status: 401, message: ex.message})); } }); // Cast pagination query parameters from strings to numbers... this.use(({query}, res, next) => { for (let param of ["startIndex", "count"]) { // ...but only if they were defined, were strings, and are valid as numbers if (!!query[param] && typeof query[param] === "string" && !Number.isNaN(+query[param])) { query[param] = +query[param]; } } next(); }); // Register core service provider endpoints this.use(new Schemas()); this.use(new ResourceTypes()); this.use(new ServiceProviderConfig()); this.use(new Search(context)); this.use(new Bulk(context)); this.use(new Me(handler, context)); // Register endpoints for any declared resource types for (let Resource of Object.values(SCIMMY.Resources.declared())) { this.use(Resource.endpoint, new Resources(Resource, context)); } // If we get to this point, there's no matching endpoints this.use((req, res) => res.status(404).send(new SCIMMY.Messages.Error({status: 404, message: "Endpoint Not Found"}))); // Handle any middleware exceptions, and if necessary, forward to next middleware this.use((ex, req, res, next) => { res.status(ex.status ?? 500).send(new SCIMMY.Messages.Error(ex)); if ((ex.status ?? 500) >= 500) next(ex); }); } } export { SCIMMYRouters, SCIMMYRouters as default };