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
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 30s
This commit is contained in:
parent
38cd098cab
commit
6ac9c2518f
5 changed files with 165 additions and 8 deletions
|
|
@ -39,7 +39,7 @@
|
||||||
"create-ba-web-app": "node ./bin/create-project.js"
|
"create-ba-web-app": "node ./bin/create-project.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@BridgemanAccessible/ba-auth": "^1.0.35",
|
"@BridgemanAccessible/ba-auth": "^1.0.36",
|
||||||
"@BridgemanAccessible/ba-logging": "^1.0.1",
|
"@BridgemanAccessible/ba-logging": "^1.0.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import axios from 'axios';
|
||||||
import { Scopes } from '@BridgemanAccessible/ba-auth';
|
import { Scopes } from '@BridgemanAccessible/ba-auth';
|
||||||
import Client from '@BridgemanAccessible/ba-auth/client';
|
import Client from '@BridgemanAccessible/ba-auth/client';
|
||||||
import type { OnAuthCallback } 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 { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging';
|
||||||
|
|
||||||
import { App } from '../App.js';
|
import { App } from '../App.js';
|
||||||
import { Initializer } from '../Initializer.js';
|
import { Initializer } from '../Initializer.js';
|
||||||
|
|
||||||
import { getValueFromEnvironmentVariable } from '../utils/env-vars.js';
|
import { getValueFromEnvironmentVariable } from '../utils/env-vars.js';
|
||||||
|
import { HealthCheckableRequestClient } from '../utils/HealthCheckableRequestClient.js';
|
||||||
|
|
||||||
import type { BridgemanAccessibleAppClaims } from './types/BridgemanAccessibleAppClaims.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 {
|
export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extends App {
|
||||||
private onAuth: OnAuthCallback;
|
private onAuth: OnAuthCallback;
|
||||||
private saveSecret: (secret: string) => void | Promise<void>;
|
private saveSecret: (secret: string) => void | Promise<void>;
|
||||||
|
private getAccessToken: () => Promise<string>;
|
||||||
|
|
||||||
private options: OAuthAppOptions<TCustomClaims>;
|
private options: OAuthAppOptions<TCustomClaims>;
|
||||||
|
|
||||||
|
|
@ -153,11 +156,13 @@ export class OAuthApp<TCustomClaims extends BridgemanAccessibleAppClaims> extend
|
||||||
constructor(
|
constructor(
|
||||||
onAuth: OnAuthCallback,
|
onAuth: OnAuthCallback,
|
||||||
saveSecret: (secret: string) => void | Promise<void>,
|
saveSecret: (secret: string) => void | Promise<void>,
|
||||||
|
getAccessToken: () => Promise<string>,
|
||||||
options?: OAuthAppOptions<TCustomClaims>
|
options?: OAuthAppOptions<TCustomClaims>
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.onAuth = onAuth;
|
this.onAuth = onAuth;
|
||||||
this.saveSecret = saveSecret;
|
this.saveSecret = saveSecret;
|
||||||
|
this.getAccessToken = getAccessToken;
|
||||||
this.options = options ?? {} as OAuthAppOptions<TCustomClaims>;
|
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
|
// Because we need this for registration to work properly. It make sense to put it here
|
||||||
app.getInitializer()
|
app.getInitializer()
|
||||||
.getRouter()
|
.getRouter()
|
||||||
.addOutsideFrameworkRoute('/.well-known/jwks.json');
|
.addOutsideFrameworkRoute(BaseKeystore.WELL_KNOWN_URI);
|
||||||
|
|
||||||
this.client = await Client.setup<TCustomClaims>(
|
this.client = await Client.setup<TCustomClaims>(
|
||||||
app.getExpressApp(),
|
app.getExpressApp(),
|
||||||
baseAppUrl,
|
baseAppUrl,
|
||||||
this.onAuth,
|
this.onAuth,
|
||||||
this.saveSecret,
|
this.saveSecret,
|
||||||
|
this.getAccessToken,
|
||||||
{
|
{
|
||||||
client_abbreviation: appAbbrv,
|
client_abbreviation: appAbbrv,
|
||||||
subscription_required: this.options.subscription_required ?? false,
|
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);
|
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 new Promise(resolve => setTimeout(resolve, 1000 /* 1 second */ * 60 /* 1 minute */ + 1000 /* 1 second */ * 60 /* 1 minute */ * (5 ** j) /* Exponential backoff */));
|
||||||
|
|
||||||
await axios.get(
|
await (new HealthCheckableRequestClient('Authorization Server')).makeRequest(
|
||||||
`https://account.bridgemanaccessible.ca/api/v1/apps/${encodeURIComponent(baseAppUrl.toString())}/status`,
|
new URL(`https://account.bridgemanaccessible.ca/api/v1/apps/${encodeURIComponent(baseAppUrl.toString())}/status`),
|
||||||
|
'GET',
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
|
|
|
||||||
135
src/utils/HealthCheckableRequestClient.ts
Normal file
135
src/utils/HealthCheckableRequestClient.ts
Normal 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
6
src/utils/utils.ts
Normal 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}`;
|
||||||
|
}
|
||||||
17
yarn.lock
17
yarn.lock
|
|
@ -2,11 +2,12 @@
|
||||||
# yarn lockfile v1
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
"@BridgemanAccessible/ba-auth@^1.0.35":
|
"@BridgemanAccessible/ba-auth@^1.0.36":
|
||||||
version "1.0.35"
|
version "1.0.36"
|
||||||
resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth/-/ba-auth-1.0.35.tgz#25d4f7149d3281fb69acd73674cf6de9858622a9"
|
resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth/-/ba-auth-1.0.36.tgz#7654d37d3e078cd0c3b226d05b15e873437133b5"
|
||||||
integrity sha512-vxmSUTXcuw00Hj0xvTe3tx0D19yoOhQVuBo/vjDLJABXjJO7/th+KHGukJOghmlnERCLv+DgGJTxq65K+ikNZQ==
|
integrity sha512-9e1OYDhUhkl38FmL2wMhPjfqsy2Pt/+o0WQtHne2cPMDAoz4h0cat9NE3lGDnCn1LMkUXaCXvNXoRUDJkUpFJw==
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@BridgemanAccessible/ba-auth_adapters" "^1.0.1"
|
||||||
"@BridgemanAccessible/ba-auth_keystore" "^1.0.1"
|
"@BridgemanAccessible/ba-auth_keystore" "^1.0.1"
|
||||||
"@BridgemanAccessible/ba-logging" "^1.0.1"
|
"@BridgemanAccessible/ba-logging" "^1.0.1"
|
||||||
argon2 "^0.40.1"
|
argon2 "^0.40.1"
|
||||||
|
|
@ -18,6 +19,14 @@
|
||||||
psl "^1.15.0"
|
psl "^1.15.0"
|
||||||
uuid "^9.0.1"
|
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":
|
"@BridgemanAccessible/ba-auth_keystore@^1.0.1":
|
||||||
version "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"
|
resolved "https://npm.pkg.bridgemanaccessible.ca/@BridgemanAccessible/ba-auth_keystore/-/ba-auth_keystore-1.0.1.tgz#79538f38fed5770a146eb34680f77f68604285a5"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue