ba-web-framework/src/oauth/OAuthApp.ts
Alan Bridgeman 6ac9c2518f
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 30s
Made needed changes for adapter architecture + added HealthCheckableRequestClient that checks service health before sending request
2026-02-20 22:08:14 -06:00

324 lines
No EOL
16 KiB
TypeScript

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';
interface BasicOAuthAppOptions {
// ------------------
// Basic app metadata
// ------------------
/** The base URL of the app */
baseAppUrl?: URL,
/** The abbreviation of the app */
//appAbbrv?: string,
/** The (potentially localized) name of the app */
appName?: string | {
/** Localized versions of the app name */
[language: string]: string
},
// -----------------------------------------------------------
// Necessary interconnected details (contacts, scopes, etc...)
// -----------------------------------------------------------
/** The email addresses of the contacts for the app */
contacts?: string[],
/** The "available" scopes (scopes an app token COULD ask for - token scopes would have to ask for this or a subset of this list) */
scopes?: Scopes[],
// -----------------------------------------------------
// Optional Mechanical Details (how the OAuth app works)
// -----------------------------------------------------
/** The type of keystore to use for managing keys */
keystoreType?: string,
/** 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'
// -----------------------------------------------------
// For already registered apps (to stop re-registration)
// -----------------------------------------------------
/** The client secret for the app (if this IS set registration WON'T be done. Because re-registering isn't supported) */
client_secret?: string
// ----------------------------------
// URL stuff (policies, logo, etc...)
// ----------------------------------
/** 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,
// ------------------------------
// Webhooks (async notifications)
// ------------------------------
/** The webhooks supported by the app */
//webhooks?: Webhook[],
// ------------------------------------------
// Purchasable Stuff (Subscriptions + Addons)
// ------------------------------------------
/** If a subscription is required */
//subscriptionRequired?: boolean,
/** The subscription tiers available for the app */
//subscriptionTiers?: AppSubscriptionTier[],
/** Addons offered by the app */
//addons?: Addon[],
};
type OAuthAppOptions<T extends BridgemanAccessibleAppClaims> = T & BasicOAuthAppOptions;
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>;
private client: Client<TCustomClaims>;
/**
* 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 |
* | 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) |
* | 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 |
*
* @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.client_abbreviation 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.keystoreType The type of keystore to use for managing keys
* @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)
* @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.webhooks The webhooks supported by the app
* @param options.subscription_required If a subscription is required
* @param options.subscription_tiers The subscription tiers available for the app
* @param options.addons Addons offered by the app
*/
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>;
}
/** Returns the OAuthApp's Client instance (which is useful for managing keys, creating resource request, etc...) */
getClient(): Client<TCustomClaims> {
return this.client;
}
/**
* 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: App) {
// If the base URL of the app isn't provided, get it from the environment variable
let baseAppUrl = this.options.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.options.client_abbreviation;
if(typeof appAbbrv === 'undefined') {
appAbbrv = getValueFromEnvironmentVariable('APP_ABBRV', { description: 'app abbreviation', blank_allowed: false });
}
logMessage(`Attempting to create/register the app:\n\tApp Base URL: ${baseAppUrl}\n\tApp Abbreviation: ${appAbbrv}\n\tApp Name: ${typeof this.options.appName === 'undefined' ? 'uses APP_NAME environment variable (' + process.env.APP_NAME + ')' : this.options.appName}\n\tScopes: ${typeof this.options.scopes === 'undefined' ? 'uses SCOPES environment variable (' + process.env.SCOPES + ')' : this.options.scopes.join(', ')}`, LogLevel.DEBUG);
// Because we need this for registration to work properly. It make sense to put it here
app.getInitializer()
.getRouter()
.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,
subscription_tiers: this.options.subscription_tiers,
addons: this.options.addons,
webhooks: this.options.webhooks
} as TCustomClaims,
this.options.appName,
this.options.scopes,
{
contacts: this.options.contacts,
logo_url: this.options.logo_url,
tos_url: this.options.tos_url,
policy_url: this.options.policy_url,
keystoreType: this.options.keystoreType,
auth_default_method: this.options.auth_default_method,
auth_default_use_JWT: this.options.auth_default_use_JWT,
auth_default_response_mode: this.options.auth_default_response_mode,
client_secret: this.options.client_secret
}
)
// If the app is "inactive" (in a "pending" state etc...), we want to try to wait for it to become active
// We use a progress backoff strategy to avoid hammering the server with requests
// Because this is largely based on human intervention, exponentiation of the index by 5 (minutes), to a maximum of about 13 hours seemed reasonable
let active = this.client.isActive();
if(!active) {
for(let j = 0;j < 5 && !active;j++) {
// Sleep for:
// 0: ! Minute
// 1: 5 Minutes
// 2: 25 Minutes
// 3: 125 Minutes (~2 hours)
// 4: 625 Minutes (~10 hours)
// Total of ~13 hours (~780 minutes)
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 HealthCheckableRequestClient('Authorization Server')).makeRequest(
new URL(`https://account.bridgemanaccessible.ca/api/v1/apps/${encodeURIComponent(baseAppUrl.toString())}/status`),
'GET',
{
headers: {
Accept: 'application/json'
}
}
)
.then((response) => {
if(response.status === 200) {
const data = response.data;
if(data.status === 'active') {
logMessage('OAuth client is now active!', LogLevel.INFO);
active = true;
}
else {
logMessage('OAuth client is still not active.', LogLevel.DEBUG);
}
}
else {
logMessage(`Received non-200 response when checking OAuth client status: ${response.status}`, LogLevel.ERROR);
}
})
.catch((error) => {
logMessage(`Error checking OAuth client status: ${error}`, LogLevel.ERROR);
});
}
}
this.client.getSetupRoutes().forEach((route) => {
logMessage(`Adding outside framework route: ${route}`, LogLevel.DEBUG);
app.getInitializer().getRouter().addOutsideFrameworkRoute(route);
});
}
/**
* 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)
*/
private async onStart(app: App, callback?: (app: OAuthApp<TCustomClaims>) => void | Promise<void>) {
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(this);
}
}
catch(err) {
logMessage('Error setting up the BA User Auth', LogLevel.ERROR);
logMessage('---------------------------------', LogLevel.ERROR);
logMessage(JSON.stringify(err), LogLevel.ERROR);
}
}
/**
* 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<T extends Initializer>(initializer?: T, callback?: (app: OAuthApp<TCustomClaims>) => void | Promise<void>) {
await super.run(initializer, async (app: App) => this.onStart(app, callback));
}
}