Made needed changes for adapter architecture + added HealthCheckableRequestClient that checks service health before sending request
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 30s

This commit is contained in:
Alan Bridgeman 2026-02-20 22:08:14 -06:00
parent 38cd098cab
commit 6ac9c2518f
5 changed files with 165 additions and 8 deletions

View file

@ -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",

View file

@ -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 extends BridgemanAccessibleAppClaims> = T & BasicOAuthApp
export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extends App {
private onAuth: OnAuthCallback;
private saveSecret: (secret: string) => void | Promise<void>;
private getAccessToken: () => Promise<string>;
private options: OAuthAppOptions<TCustomClaims>;
@ -153,11 +156,13 @@ export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extend
constructor(
onAuth: OnAuthCallback,
saveSecret: (secret: string) => void | Promise<void>,
getAccessToken: () => Promise<string>,
options?: OAuthAppOptions<TCustomClaims>
) {
super();
this.onAuth = onAuth;
this.saveSecret = saveSecret;
this.getAccessToken = getAccessToken;
this.options = options ?? {} as OAuthAppOptions<TCustomClaims>;
}
@ -192,13 +197,14 @@ export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> 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<TCustomClaims>(
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<TCustomClaims extends BridgemanAccessibleAppClaims> 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'

View file

@ -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}`);
}
}
}

6
src/utils/utils.ts Normal file
View file

@ -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}`;
}

View file

@ -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"