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 & BasicOAuthAppOptions; export class OAuthApp extends App { private onAuth: OnAuthCallback; private saveSecret: (secret: string) => void | Promise; private getAccessToken: () => Promise; private options: OAuthAppOptions; private client: Client; /** * 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, getAccessToken: () => Promise, options?: OAuthAppOptions ) { super(); this.onAuth = onAuth; this.saveSecret = saveSecret; this.getAccessToken = getAccessToken; this.options = options ?? {} as OAuthAppOptions; } /** Returns the OAuthApp's Client instance (which is useful for managing keys, creating resource request, etc...) */ getClient(): Client { 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( 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) => 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(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(initializer?: T, callback?: (app: OAuthApp) => void | Promise) { await super.run(initializer, async (app: App) => this.onStart(app, callback)); } }