From 74997cb676d10a51df6a3bd89e90fac96c1c15b9 Mon Sep 17 00:00:00 2001 From: Alan Bridgeman Date: Tue, 29 Apr 2025 12:06:39 -0500 Subject: [PATCH] Bringing local and remote repository in line with one another and adding Github Actions to publish package --- .github/workflows/publish.yml | 53 ++++++ src/OAuthApp.ts | 198 +++++++++++++++++++++++ src/controllers/ErrorController.ts | 9 ++ src/decorators/DELETE.ts | 21 +++ src/decorators/ErrorHandler.ts | 62 +++++++ src/decorators/PUT.ts | 21 +++ src/middlewares/HealthCheckMiddleware.ts | 49 ++++++ src/utils/env-vars.ts | 66 ++++++++ 8 files changed, 479 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 src/OAuthApp.ts create mode 100644 src/controllers/ErrorController.ts create mode 100644 src/decorators/DELETE.ts create mode 100644 src/decorators/ErrorHandler.ts create mode 100644 src/decorators/PUT.ts create mode 100644 src/middlewares/HealthCheckMiddleware.ts create mode 100644 src/utils/env-vars.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6a4b9da --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,53 @@ +name: Publish to Private NPM Registry + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + # Checkout the repository + - name: Checkout code + uses: actions/checkout@v3 + + # Set up NPM Auth Token + - name: Set up NPM Auth Token + run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV + + # Set up Node.js + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + # Taken from [Repo README](https://github.com/actions/setup-node#readme) + # + # > Version Spec of the version to use in SemVer notation. + # > It also admits such aliases as lts/*, latest, nightly and canary builds + # > Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node + node-version: 'latest' + + # Taken from [Repo README](https://github.com/actions/setup-node#readme) + # + # > Optional registry to set up for auth. Will set the registry in a project level .npmrc and .yarnrc file, + # > and set up auth to read in from env.NODE_AUTH_TOKEN. + # > Default: '' + registry-url: 'https://npm.pkg.bridgemanaccessible.ca' + + # Taken from [Repo README](https://github.com/actions/setup-node#readme) + # + # > Optional scope for authenticating against scoped registries. + # > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/). + scope: '@BridgemanAccessible' + + # Install dependencies + - name: Install dependencies + run: | + yarn install + yarn build + + # Publish to private NPM registry + - name: Publish package + run: npm publish \ No newline at end of file diff --git a/src/OAuthApp.ts b/src/OAuthApp.ts new file mode 100644 index 0000000..62db26a --- /dev/null +++ b/src/OAuthApp.ts @@ -0,0 +1,198 @@ +import { Application } from 'express'; +import { Scopes } from '@BridgemanAccessible/ba-auth'; +import Client, { OnAuthCallback } from '@BridgemanAccessible/ba-auth/client'; + +import { App } from './App'; +import { Initializer } from './Initializer'; + +import { getValueFromEnvironmentVariable } from './utils/env-vars'; + +type OAuthAppOptions = { + /** The base URL of the app */ + baseAppUrl?: URL, + /** The abbreviation of the app */ + appAbbrv?: string, + /** The name of the app */ + appName?: string | { + /** Localized versions of the app name */ + [language: string]: string + }, + /** The email addresses of the contacts for the app */ + contacts?: string[], + /** The scopes an app token COULD ask for (token scopes would have to ask for this or a subset of this list) */ + scopes?: Scopes[], + /** The URL of the app's logo */ + logo_url?: URL, + /** The URL of the app's terms of service */ + tos_url?: URL, + /** The URL of the app's privacy policy */ + policy_url?: URL, + /** The type of vault to use for the keystore */ + vault_type?: "azure" | "hashicorp" | "file", + /** The default method for authentication */ + auth_default_method?: "query" | "form_post" | "PAR", + /** Whether to use JWT as the default authentication method */ + auth_default_use_JWT?: boolean, + /** The default response mode for authentication */ + auth_default_response_mode?: 'query' | 'fragment' | 'form_post' + /** The client secret for the app (if this IS set registration WON'T be done. Because re-registering isn't supported) */ + client_secret?: string +}; + +export class OAuthApp extends App { + private onAuth: OnAuthCallback; + private saveSecret: (secret: string) => void | Promise; + + private baseAppUrl?: URL; + private appAbbrv?: string; + private appName?: string | { [language: string]: string }; + private contacts?: string[]; + private scopes?: Scopes[]; + private logo_url?: URL; + private tos_url?: URL; + private policy_url?: URL; + private vault_type?: "azure" | "hashicorp" | "file"; + private auth_default_method?: "query" | "form_post" | "PAR"; + private auth_default_use_JWT?: boolean; + private auth_default_response_mode?: 'query' | 'fragment' | 'form_post'; + private client_secret?: string; + + /** + * Create a new OAuth app + * + * This is a more specialized version of the `App` class that is designed to work with the Bridgeman Accessible OAuth system. + * The only required parameters are the `onAuth` and `saveSecret` callbacks which are called when a user logs in. + * And when the client secret is generated (and is to be saved) respectively. + * The `options` parameter allows for tweaking the OAuth details for the app. + * + * Note, the following table of environment variables that are used to set the OAuth details if they aren't provided in the `options` parameter: + * + * | Environment Variable | Is Required | Description | + * | -------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- | + * | BASE_APP_URL | Required if `options.baseAppUrl` isn't provided | The base URL of the client (app) | + * | APP_ABBRV | Required if `options.appAbbrv` isn't provided | The abbreviation of the client (app) | + * | APP_NAME | Required if `options.appName` isn't provided | The name of the client (only a single non-localized name is supported) | + * | CONTACT_EMAIL | Required if `options.contacts` isn't provided | A comma-separated list of email addresses to list as contacts for the client | + * | LOGO_URL | Optional (used if `options.logo_url` isn't specified) | The URL of the client's (app's) logo | + * | TOS_URL | Optional (used if `options.tos_url` isn't specified) | The URL of the client's terms of service | + * | POLICY_URL | Optional (used if `options.policy_url` isn't specified) | The URL of the client's privacy policy | + * | SCOPES | Required if `options.scopes` isn't provided | A comma-separated list of scopes available to the client | + * | VAULT_TYPE | Required if in production (`NODE_ENV` is `production`) | The type of vault to use for the keystore (one of azure, hashicorp, or file) | + * | APP_SECRET | Optional (Determines if app should register or not) | The client secret for the app (if this IS set registration WON'T be done) | + * + * @param onAuth The callback to call when a user logs in + * @param saveSecret The callback to call to save the secret + * @param options The options for the OAuth app + * @param options.baseAppUrl The base URL of the app + * @param options.appAbbrv The abbreviation of the app (used for user properties associated with the app) + * @param options.appName The name of the app + * @param options.contacts The email addresses of the contacts for the app + * @param options.scopes The scopes an app token COULD ask for (token scopes would have to ask for this or a subset of this list) + * @param options.logo_url The URL of the app's logo + * @param options.tos_url The URL of the app's terms of service + * @param options.policy_url The URL of the app's privacy policy + * @param options.vault_type The type of vault to use for the keystore + * @param options.auth_default_method The default method for authentication + * @param options.auth_default_use_JWT Whether to use JWT as the default authentication method + * @param options.auth_default_response_mode The default response mode for authentication + * @param options.client_secret The client secret for the app (if this IS set registration WON'T be done. Because re-registering isn't supported) + */ + constructor(onAuth: OnAuthCallback, saveSecret: (secret: string) => void | Promise, options?: OAuthAppOptions) { + super(); + this.onAuth = onAuth; + this.saveSecret = saveSecret; + + if(typeof options !== 'undefined') { + this.baseAppUrl = options.baseAppUrl; + this.appAbbrv = options.appAbbrv; + this.appName = options.appName; + this.contacts = options.contacts; + this.scopes = options.scopes; + this.logo_url = options.logo_url; + this.tos_url = options.tos_url; + this.policy_url = options.policy_url; + this.vault_type = options.vault_type; + this.auth_default_method = options.auth_default_method; + this.auth_default_use_JWT = options.auth_default_use_JWT; + this.auth_default_response_mode = options.auth_default_response_mode; + this.client_secret = options.client_secret; + } + } + + /** + * Setup the OAuth client + * + * This is done here because we need the client to be serving/listening for request in order for the auth library stuff to work properly + * (mostly, the server needs to be able to get the client's keys which the client needs to be able to serve) + * + * @param app The app to setup the OAuth client for + */ + private async setupOAuthClient(app: Application) { + // If the base URL of the app isn't provided, get it from the environment variable + let baseAppUrl = this.baseAppUrl; + if(typeof baseAppUrl === 'undefined') { + baseAppUrl = new URL(getValueFromEnvironmentVariable('BASE_APP_URL', { description: 'app\'s base URL', blank_allowed: false })); + } + + // The app abbreviation (used for user properties associated with the app) + let appAbbrv = this.appAbbrv; + if(typeof appAbbrv === 'undefined') { + appAbbrv = getValueFromEnvironmentVariable('APP_ABBRV', { description: 'app abbreviation', blank_allowed: false }); + } + + console.log(`Attempting to create/register the app:\n\tApp Base URL: ${baseAppUrl}\n\tApp Abbreviation: ${appAbbrv}\n\tApp Name: ${typeof this.appName === 'undefined' ? 'uses APP_NAME environment variable (' + process.env.APP_NAME + ')' : this.appName}\n\tScopes: ${typeof this.scopes === 'undefined' ? 'uses SCOPES environment variable (' + process.env.SCOPES + ')' : this.scopes.join(', ')}`); + + await Client.setup(app, baseAppUrl, this.onAuth, this.saveSecret, appAbbrv, this.appName, this.scopes, { + contacts: this.contacts, + logo_url: this.logo_url, + tos_url: this.tos_url, + policy_url: this.policy_url, + vault_type: this.vault_type, + auth_default_method: this.auth_default_method, + auth_default_use_JWT: this.auth_default_use_JWT, + auth_default_response_mode: this.auth_default_response_mode, + client_secret: this.client_secret + }); + } + + /** + * The wrapper method around the `onStart` callback that differentiates the `OAuthApp` from the `App` class. + * That is, this (the `OAuthApp` class) sets up the OAuth client before calling the client's `onStart` callback. + * As opposed to the `App` class which just calls the `onStart` callback. + * + * It's done this way so that it's almost entirely transparent the difference between apps that have OAuth and those that don't. + * + * @param app The Express app object (that is now listening) + */ + async onStart(app: Application, callback?: (app: Application) => void | Promise) { + try { + // Setup the OAuth client. + // This is done here because we need the client to be serving/listening for requests for the auth library stuff to work + // (mostly because the server needs to be able to get the client's keys which it needs to be able to serve) + await this.setupOAuthClient(app); + + if(typeof callback !== 'undefined') { + await callback.bind(this)(app); + } + } + catch(err) { + console.error('Error setting up the BA User Auth'); + console.error('---------------------------------'); + console.error(err); + } + } + + /** + * The main entry point for the app + * + * This largely just wraps the `run` method from the parent class (`App`). + * However, instead of invoking the provided callback immediately when the client starts. + * This initializes the app as an OAuth app first and then calls the callback. + * This lets the app just worry about the `onAuth` callback (what happens when a user logs in) and `saveSecret` callback (that saves the generated client secret, if applicable) + * And doesn't have to worry about the OAuth details to make this work. + * Though it does provide tweaking the OAuth details via options provided to the constructor. + */ + async run(initializer?: T, callback?: (app: Application) => void | Promise) { + await super.run(initializer, async (app: Application) => this.onStart(app, callback)); + } +} \ No newline at end of file diff --git a/src/controllers/ErrorController.ts b/src/controllers/ErrorController.ts new file mode 100644 index 0000000..dbe9982 --- /dev/null +++ b/src/controllers/ErrorController.ts @@ -0,0 +1,9 @@ +import { Request, Response, NextFunction } from 'express'; + +export abstract class ErrorController { + /** A human readable string to describe the error this controller handles. */ + public handlesError?: string; + + + abstract handle(error: unknown, req: Request, res: Response, next: NextFunction): any | Promise; +} \ No newline at end of file diff --git a/src/decorators/DELETE.ts b/src/decorators/DELETE.ts new file mode 100644 index 0000000..4e1004e --- /dev/null +++ b/src/decorators/DELETE.ts @@ -0,0 +1,21 @@ +import { NextFunction } from 'express'; +import { NextHandleFunction } from 'connect'; + +export const DELETE_METADATA_KEY = 'Delete'; + +/** + * Method decorator intended to be used on methods of a class decorated with the `@Controller` decorator. + * + * This in conjunction with the `@Controller` decorator will automatically setup a DELETE route that executes the decorated method. + * The specific path for the route is defined by the path parameter. + * Controller authors can also specify middleware to be used with the DELETE route (because a middleware is commonly required to parse the body of a DELETE request). + * + * @param path The path for the DELETE route. + * @param middleware The middleware to use with the DELETE route. + */ +export function DELETE(path: string, ...middleware: (NextHandleFunction | NextFunction)[]) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + // Define a metadata key with the path and middleware as the value on the target's propertyKey + Reflect.defineMetadata(DELETE_METADATA_KEY, { path, middleware }, target, propertyKey); + }; +} \ No newline at end of file diff --git a/src/decorators/ErrorHandler.ts b/src/decorators/ErrorHandler.ts new file mode 100644 index 0000000..0acf630 --- /dev/null +++ b/src/decorators/ErrorHandler.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from 'express'; + +import { ErrorController } from '../controllers/ErrorController'; + +/** + * Class decorator to create custom error handling for the app. + * + * That is, so that custom error pages can easily be created and used in the app. + * + * @example + * The following example show how to use the Controller decorator to setup a path `/path` with a GET, POST, PUT and DELETE method. + * ```ts + * import { Request, Response, NextFunction } from 'express'; + * + * import { ErrorController, ErrorHandler } from '@BridgemanAccessible/ba-web-framework'; + * + * @ErrorHandler(404) + * export class Custom404PageController extends ErrorController { + * handle(error: unknown, req: Request, res: Response, next: NextFunction) { + * // ... + * } + * } + * ``` + * + * @param errorCode The error code to handle. This is the status code that will be returned by the server. + * @param description A human readable string to describe the error this controller handles. This is used for debugging purposes only and is not used in the app itself. + */ +export function ErrorHandler(errorCode: number, description?: string) { + // Technically the function we return here with the target parameter is the actual decorator itself but we wrap it in case we ever want to add parameters to the decorator + return function(target: T){ + Reflect.defineMetadata('errorHandler', target, target); + + if(typeof target.prototype.handlesError === 'undefined') { + // If the handlesError property is not set, we set it to the description passed to the decorator + target.prototype.handlesError = description || `${errorCode}`; + } + + // We extend the class that is decorated and override the setup method to automatically setup the routes + return class extends target { + async handle(error: unknown, req: Request, res: Response, next: NextFunction) { + // Because the headers have already been sent, we cannot send a response again. + // So we check if the headers have been sent and if so, we call the next middleware (default) in the error chain. + if(res.headersSent) { + return next(error); + } + + // Create an instance of the decorated (original) class + const controller: ErrorController = new (Reflect.getMetadata('errorHandler', target))(); + + // We only want to call the handle method if the error code matches the one denoted. + if(res.statusCode === errorCode) { + // Call the handle method of the controller and return the result + return await controller.handle.bind(controller)(error, req, res, next); + } + else { + // If the error code does not match, we call the next middleware in the error chain + return next(error); + } + } + } + } +} \ No newline at end of file diff --git a/src/decorators/PUT.ts b/src/decorators/PUT.ts new file mode 100644 index 0000000..0cc9370 --- /dev/null +++ b/src/decorators/PUT.ts @@ -0,0 +1,21 @@ +import { NextFunction } from 'express'; +import { NextHandleFunction } from 'connect'; + +export const PUT_METADATA_KEY = 'Put'; + +/** + * Method decorator intended to be used on methods of a class decorated with the `@Controller` decorator. + * + * This in conjunction with the `@Controller` decorator will automatically setup a PUT route that executes the decorated method. + * The specific path for the route is defined by the path parameter. + * Controller authors can also specify middleware to be used with the PUT route (because a middleware is commonly required to parse the body of a PUT request). + * + * @param path The path for the PUT route. + * @param middleware The middleware to use with the PUT route. + */ +export function PUT(path: string, ...middleware: (NextHandleFunction | NextFunction)[]) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + // Define a metadata key with the path and middleware as the value on the target's propertyKey + Reflect.defineMetadata(PUT_METADATA_KEY, { path, middleware }, target, propertyKey); + }; +} \ No newline at end of file diff --git a/src/middlewares/HealthCheckMiddleware.ts b/src/middlewares/HealthCheckMiddleware.ts new file mode 100644 index 0000000..3e82d85 --- /dev/null +++ b/src/middlewares/HealthCheckMiddleware.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +export enum HealthCheckStatus { + OK = 'ok', + ERROR = 'error' +} + +export class HealthCheckMiddleware { + public static readonly HEALTH_CHECK_ENDPOINT = '/.well-known/health-check'; + + private static status: HealthCheckStatus; + + constructor(initialStatus: HealthCheckStatus = HealthCheckStatus.OK) { + HealthCheckMiddleware.status = initialStatus; + } + + static setStatus(status: HealthCheckStatus) { + HealthCheckMiddleware.status = status; + } + + middleware(req: Request, res: Response, next: NextFunction) { + if(req.path === HealthCheckMiddleware.HEALTH_CHECK_ENDPOINT) { + switch(HealthCheckMiddleware.status) { + case HealthCheckStatus.OK: + return res.status(200).setHeader('Content-Type', 'application/health+json').json({ status: 'ok' }); + break; + case HealthCheckStatus.ERROR: + return res.status(500).setHeader('Content-Type', 'application/health+json').json({ status: 'error' }); + break; + } + } + + next(); + } +} + +/** + * Middleware function to create a health check endpoint. + * + * This attempts to comply with the [Health Check Response Format for HTTP APIs](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check-06) proposed standard. + * + * @param initialStatus The initial status of the health check. + * @returns The middleware function. + */ +export function healthCheckMiddleware(initialStatus: HealthCheckStatus = HealthCheckStatus.OK) { + const instance = new HealthCheckMiddleware(initialStatus); + + return instance.middleware.bind(instance); +} \ No newline at end of file diff --git a/src/utils/env-vars.ts b/src/utils/env-vars.ts new file mode 100644 index 0000000..ff96eab --- /dev/null +++ b/src/utils/env-vars.ts @@ -0,0 +1,66 @@ +/** + * Strip quotes from a string (if they are present) + * + * This is useful for environment variables because otherwise there can be unexpected behaviors between quoted and unquoted values + * + * @param value The value to strip quotes from + * @returns The value without quotes + */ +function stripQuotes(value: string): string { + // Note we check BOTH the start and end of the string for quotes + // This is because we don't want to strip quotes from the middle of the string + // Or if for one reason or another there are quotes at one end but not the other + // + // For instance, `abc"value"abc` should NOT be stripped to `abcvalueabc` + // Further, `"value"abc` should NOT be stripped to `valueabc` + // Similarly, `value"abc"` should NOT be stripped to `valueabc` + // But `"value"` SHOULD BE stripped to `value` + // + // In theory, the quotes should be used to avoid issues with spaces and other special characters and the encapsulated value should be treated as a single value and quotes removed + // But, for now, we'll just strip the quotes as described above + if(value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + + return value; +} + +/** + * Get the value from an environment variable in a safe way + * + * @param varname The name of the environment variable to get the value from + * @param options Options for getting the value from the environment variable. Optional. + * @param options.description A human readable description of the environment variable (used for error messages). Optional. + * @param options.default The default value to use if the environment variable is not defined. Optional. + * @param options.blank_allowed Whether a blank value is allowed. Optional. Default is true (blanks are allowed). + * @throws If the environment variable is not defined + * @returns The value of the environment variable + */ +export function getValueFromEnvironmentVariable(varname: string, options?: { description?: string, default?: string, blank_allowed?: boolean }): string { + // Verify if the environment variable is defined + if(typeof process.env[varname] === 'undefined' || process.env[varname] === null) { + if(typeof options !== 'undefined' && typeof options.default !== 'undefined') { + // Because the variable is not defined, but a default value is provided, return the default value + return options.default; + } + else { + // Because the variable is not defined AND no default value is provided, throw an error + throw new Error(`The ${typeof options !== 'undefined' && typeof options.description !== 'undefined' ? options.description : ''} (${varname} environment variable) is not defined`); + } + } + + // If the value is blank and blanks are EXPLICITLY not allowed, return the default or throw an error + if(typeof options !== 'undefined' && typeof options.blank_allowed !== 'undefined' && !options.blank_allowed && process.env[varname] === '') { + if(typeof options.default !== 'undefined') { + // Because the variable is blank, but blanks aren't allowed AND a default value is provided, return the default value + return options.default; + } + else { + // Because the variable is blank, but blanks aren't allowed AND no default value is provided, throw an error + throw new Error(`The ${typeof options.description !== 'undefined' ? options.description : ''} (${varname} environment variable) cannot be blank`); + } + } + + // Strip quotes from the value before returning it + return stripQuotes(process.env[varname]); +} \ No newline at end of file