From 6ac9c2518f286a25a1e251a16dd22352b7cfd37a Mon Sep 17 00:00:00 2001 From: Alan Bridgeman Date: Fri, 20 Feb 2026 22:08:14 -0600 Subject: [PATCH] Made needed changes for adapter architecture + added HealthCheckableRequestClient that checks service health before sending request --- package.json | 2 +- src/oauth/OAuthApp.ts | 13 ++- src/utils/HealthCheckableRequestClient.ts | 135 ++++++++++++++++++++++ src/utils/utils.ts | 6 + yarn.lock | 17 ++- 5 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 src/utils/HealthCheckableRequestClient.ts create mode 100644 src/utils/utils.ts diff --git a/package.json b/package.json index 81dbdda..777b751 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "create-ba-web-app": "node ./bin/create-project.js" }, "dependencies": { - "@BridgemanAccessible/ba-auth": "^1.0.35", + "@BridgemanAccessible/ba-auth": "^1.0.36", "@BridgemanAccessible/ba-logging": "^1.0.1", "express": "^4.19.2", "fs-extra": "^11.2.0", diff --git a/src/oauth/OAuthApp.ts b/src/oauth/OAuthApp.ts index 64982f9..4fa2fb0 100644 --- a/src/oauth/OAuthApp.ts +++ b/src/oauth/OAuthApp.ts @@ -3,12 +3,14 @@ import axios from 'axios'; import { Scopes } from '@BridgemanAccessible/ba-auth'; import Client from '@BridgemanAccessible/ba-auth/client'; import type { OnAuthCallback } from '@BridgemanAccessible/ba-auth/client'; +import { BaseKeystore } from '@BridgemanAccessible/ba-auth_keystore'; import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging'; import { App } from '../App.js'; import { Initializer } from '../Initializer.js'; import { getValueFromEnvironmentVariable } from '../utils/env-vars.js'; +import { HealthCheckableRequestClient } from '../utils/HealthCheckableRequestClient.js'; import type { BridgemanAccessibleAppClaims } from './types/BridgemanAccessibleAppClaims.js'; @@ -101,6 +103,7 @@ type OAuthAppOptions = T & BasicOAuthApp export class OAuthApp extends App { private onAuth: OnAuthCallback; private saveSecret: (secret: string) => void | Promise; + private getAccessToken: () => Promise; private options: OAuthAppOptions; @@ -153,11 +156,13 @@ export class OAuthApp extend constructor( onAuth: OnAuthCallback, saveSecret: (secret: string) => void | Promise, + getAccessToken: () => Promise, options?: OAuthAppOptions ) { super(); this.onAuth = onAuth; this.saveSecret = saveSecret; + this.getAccessToken = getAccessToken; this.options = options ?? {} as OAuthAppOptions; } @@ -192,13 +197,14 @@ export class OAuthApp extend // Because we need this for registration to work properly. It make sense to put it here app.getInitializer() .getRouter() - .addOutsideFrameworkRoute('/.well-known/jwks.json'); + .addOutsideFrameworkRoute(BaseKeystore.WELL_KNOWN_URI); this.client = await Client.setup( app.getExpressApp(), baseAppUrl, this.onAuth, this.saveSecret, + this.getAccessToken, { client_abbreviation: appAbbrv, subscription_required: this.options.subscription_required ?? false, @@ -237,8 +243,9 @@ export class OAuthApp extend logMessage(`Waiting for OAuth client to become active... (waited ${j > 0 ? 60 /* 1 minute */ * (5 ** (j - 1)) / 60 : 0} minutes so far). Now waiting for ${60 /* 1 minute */ * (5 ** (j)) / 60} minutes...`, LogLevel.WARN); await new Promise(resolve => setTimeout(resolve, 1000 /* 1 second */ * 60 /* 1 minute */ + 1000 /* 1 second */ * 60 /* 1 minute */ * (5 ** j) /* Exponential backoff */)); - await axios.get( - `https://account.bridgemanaccessible.ca/api/v1/apps/${encodeURIComponent(baseAppUrl.toString())}/status`, + await (new HealthCheckableRequestClient('Authorization Server')).makeRequest( + new URL(`https://account.bridgemanaccessible.ca/api/v1/apps/${encodeURIComponent(baseAppUrl.toString())}/status`), + 'GET', { headers: { Accept: 'application/json' diff --git a/src/utils/HealthCheckableRequestClient.ts b/src/utils/HealthCheckableRequestClient.ts new file mode 100644 index 0000000..3456c6b --- /dev/null +++ b/src/utils/HealthCheckableRequestClient.ts @@ -0,0 +1,135 @@ +import axios from 'axios'; + +import logMessage, { LogLevel } from '@BridgemanAccessible/ba-logging'; + +import { HealthCheckMiddleware } from '../middlewares/HealthCheckMiddleware.js'; + +import { joinUrl } from './utils.js'; + +class HealthCheckConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'HealthCheckConfigError'; + } +} + +/** A utility class for making HTTP requests that includes a pre-send health check mechanism to ensure the target server is available before making the request. */ +export class HealthCheckableRequestClient { + /** A description of the server being checked (ex. 'Authorization Server', etc...) */ + private description?: string; + + constructor(description?: string) { + this.description = description; + } + + /** + * Uses the health check endpoint to verify if the authorization server is available + * + * @returns true if the server is available, false if it is unavailable, or 'Not Found' if the health check endpoint is not found (404) + */ + private async checkServerAvailable(url: URL, timeout: number = 5000) { + let healthResponse; + try { + // Make the health check request + healthResponse = await axios.get(joinUrl(url.toString(), HealthCheckMiddleware.HEALTH_CHECK_ENDPOINT), { + headers: { + Accept: 'application/health+json' + }, + timeout: timeout + }); + } + catch (error) { + if(axios.isAxiosError(error)) { + if(error.code === 'ECONNABORTED') { + logMessage(`${this.description} health check timed out after ${timeout}ms`, LogLevel.WARN); + } + else if(error.response && error.response.status === 404) { + logMessage(`${typeof this.description !== 'undefined' ? this.description + ' ' : ''}health check endpoint not found (404). This may indicate the server is not properly configured.`, LogLevel.ERROR); + throw new HealthCheckConfigError(`${typeof this.description !== 'undefined' ? this.description + ' ' : ''}health check endpoint not found (404). This may indicate the server is not properly configured. Please ensure ${typeof this.description !== 'undefined' ? this.description + ' ' : ''} at ${url.toString()} is running and properly configured with a health check endpoint at ${joinUrl(url.toString(), HealthCheckMiddleware.HEALTH_CHECK_ENDPOINT)}`); + } + } + + logMessage(`Authorization server health check failed: ${error}`, LogLevel.ERROR); + + return false; + } + + // If we get a successful response but the status in the response body is not 'ok', + // we consider the server unavailable (since this likely indicates the server is running but not healthy/ready yet, which is effectively the same as being unavailable from the perspective of trying to register with it) + if(healthResponse?.data?.status === 'ok') { + return true; + } + + return false; + } + + /** + * Attempts to wait for the given URL (which has a `/.well-known/health-check` underneath it) to become available. + * It repeatedly checks the health check endpoint until it returns a successful response or until retries are exhausted. + * + * If the health check endpoint returns a 404 Not Found, + * it will throw an error immediately since this likely indicates a configuration issue with the Authorization Server rather than the server just being unavailable, + * and retries would not resolve this issue. + * + * @throws Error if the server is not available after the specified number of retries, or if a 404 Not Found is encountered on the health check endpoint indicating a potential configuration issue + */ + private async waitForAvailable(url: URL, retries: number = 5, timeout: number = 30000, interval: number = 5000) { + let available = false; + for(let i = 0; i < retries && !available; i++) { + let isAvailable; + try { + isAvailable = await this.checkServerAvailable(url, timeout); + } + catch (error) { + // Escape hatch for 404 Not Found to indicate the server is up but the health check endpoint is not properly configured, + // since this is a common issue that can cause confusion if the server is actually running but just doesn't have the health check endpoint set up correctly + // it's also a "unsolvable" error with retries so having it saves unnecessary retries and provides a clearer error message to the user about what the actual issue is + if(error instanceof HealthCheckConfigError) { + throw error; + } + } + + // IF the server is available, set available to true and continue to exit the loop (since the loop condition will prevent further iterations if available is true) + if(isAvailable) { + available = true; + continue; // Technically could probably also be a `break;` here since we're not doing anything else in the loop after this, but just to be explicit that we want to exit the loop if we determine the server is available + } + + logMessage(`${typeof this.description !== 'undefined' ? this.description + ' ' : ''}not available yet.`, LogLevel.DEBUG); + + // Only wait if there are retries remaining + if(i < retries - 1) { + logMessage(`Retrying (${i + 1}/${retries}) in ${interval / 1000} seconds...`, LogLevel.INFO); + + // Wait + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + // After exhausting retries, if the server is still not available, throw an error + if(!available) { + throw new Error(`${url.toString()} is not available after ${retries} attempts waiting ${interval}ms between each attempt.`); + } + } + + /** + * Makes an HTTP request to the specified URL with the given method and options, + * but first waits for the server to be available by checking the health check endpoint. + */ + async makeRequest(url: URL, method: 'GET' | 'POST' | 'PUT' | 'DELETE', options?: { headers?: { [key: string]: string }, body?: any }) { + await this.waitForAvailable(new URL(`${url.protocol}//${url.hostname}${url.port !== '' ? ':' + url.port : ''}`)); + + switch(method) { + case 'GET': + return await axios.get(url.toString(), { headers: options?.headers }); + case 'POST': + return await axios.post(url.toString(), options?.body, { headers: options?.headers }); + case 'PUT': + return await axios.put(url.toString(), options?.body, { headers: options?.headers }); + case 'DELETE': + return await axios.delete(url.toString(), { headers: options?.headers/*,*/ /*data: options?.body / Note: axios requires the body to be in the `data` field for DELETE requests */ }); + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + } +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..efd503e --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,6 @@ +/** Safely joins a base URL/string with a path, ensuring exactly one slash between them. */ +export function joinUrl(base: URL | string, path: string): string { + const baseStr = base.toString().replace(/\/+$/, ''); // Strip trailing slashes + const pathStr = path.replace(/^\/+/, ''); // Strip leading slashes + return `${baseStr}/${pathStr}`; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b3bc10e..e60ae3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,11 +2,12 @@ # yarn lockfile v1 -"@BridgemanAccessible/ba-auth@^1.0.35": - version "1.0.35" - resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth/-/ba-auth-1.0.35.tgz#25d4f7149d3281fb69acd73674cf6de9858622a9" - integrity sha512-vxmSUTXcuw00Hj0xvTe3tx0D19yoOhQVuBo/vjDLJABXjJO7/th+KHGukJOghmlnERCLv+DgGJTxq65K+ikNZQ== +"@BridgemanAccessible/ba-auth@^1.0.36": + version "1.0.36" + resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth/-/ba-auth-1.0.36.tgz#7654d37d3e078cd0c3b226d05b15e873437133b5" + integrity sha512-9e1OYDhUhkl38FmL2wMhPjfqsy2Pt/+o0WQtHne2cPMDAoz4h0cat9NE3lGDnCn1LMkUXaCXvNXoRUDJkUpFJw== dependencies: + "@BridgemanAccessible/ba-auth_adapters" "^1.0.1" "@BridgemanAccessible/ba-auth_keystore" "^1.0.1" "@BridgemanAccessible/ba-logging" "^1.0.1" argon2 "^0.40.1" @@ -18,6 +19,14 @@ psl "^1.15.0" uuid "^9.0.1" +"@BridgemanAccessible/ba-auth_adapters@^1.0.1": + version "1.0.1" + resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth_adapters/-/ba-auth_adapters-1.0.1.tgz#ba5a4bcc7e2a8a22a539165140b1900e618db535" + integrity sha512-RigrC3wbDB+MICgLyHpyunrVXUa6NpT14jVk01DYZPIXgbLURfTJhhNB8Xm8AP5/t3onMBvGoz3hNNON6jxOqQ== + dependencies: + "@BridgemanAccessible/ba-logging" "^1.0.1" + express "^5.2.1" + "@BridgemanAccessible/ba-auth_keystore@^1.0.1": version "1.0.1" resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth_keystore/-/ba-auth_keystore-1.0.1.tgz#79538f38fed5770a146eb34680f77f68604285a5"