diff --git a/packages/timeline-state-resolver-types/src/generated/httpSend.ts b/packages/timeline-state-resolver-types/src/generated/httpSend.ts index 6a38a4476..339209e67 100644 --- a/packages/timeline-state-resolver-types/src/generated/httpSend.ts +++ b/packages/timeline-state-resolver-types/src/generated/httpSend.ts @@ -19,6 +19,7 @@ export interface HTTPSendOptions { oauthClientId?: string oauthClientSecret?: string oauthAudience?: string + bearerToken?: string } export interface HTTPSendCommandContent { type: TimelineContentTypeHTTP diff --git a/packages/timeline-state-resolver/src/integrations/httpSend/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/httpSend/$schemas/options.json index a2fe2602a..238e57475 100644 --- a/packages/timeline-state-resolver/src/integrations/httpSend/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/httpSend/$schemas/options.json @@ -74,19 +74,25 @@ }, "oauthTokenHost": { "type": "string", - "ui:title": "OAuth Token Host" + "ui:title": "OAuth 2.0 Token Host", + "ui:description": "To use Client Credentials Flow, provide: OAuth 2.0 Token Host, OAuth 2.0 Client ID, OAuth 2.0 Client Secret, and optionally OAuth 2.0 Audience, to exchange them for a Bearer token that will be added to EVERY outgoing request made through this device" }, "oauthClientId": { "type": "string", - "ui:title": "OAuth Client ID" + "ui:title": "OAuth 2.0 Client ID" }, "oauthClientSecret": { "type": "string", - "ui:title": "OAuth Client Secret" + "ui:title": "OAuth 2.0 Client Secret" }, "oauthAudience": { "type": "string", - "ui:title": "OAuth Audience" + "ui:title": "OAuth 2.0 Audience" + }, + "bearerToken": { + "type": "string", + "ui:title": "Bearer Token", + "ui:descrption": "Long-lived Bearer token that will be added to EVERY outgoing request made through this device" } }, "required": ["host"], diff --git a/packages/timeline-state-resolver/src/integrations/httpSend/AuthenticatedHTTPSendDevice.ts b/packages/timeline-state-resolver/src/integrations/httpSend/AuthenticatedHTTPSendDevice.ts index 87d194dbc..ceb4c16fc 100644 --- a/packages/timeline-state-resolver/src/integrations/httpSend/AuthenticatedHTTPSendDevice.ts +++ b/packages/timeline-state-resolver/src/integrations/httpSend/AuthenticatedHTTPSendDevice.ts @@ -2,34 +2,98 @@ import { HTTPSendOptions } from 'timeline-state-resolver-types' import { HTTPSendDevice, HttpSendDeviceCommand } from '.' import { AccessToken, ClientCredentials } from 'simple-oauth2' +const TOKEN_REQUEST_RETRY_TIMEOUT_MS = 1000 +const TOKEN_EXPIRATION_WINDOW_SEC = 60 +const enum AuthMethod { + BEARER_TOKEN, + CLIENT_CREDENTIALS, +} + export class AuthenticatedHTTPSendDevice extends HTTPSendDevice { private tokenPromise: Promise | undefined - private authOptions: { clientId: string; clientSecret: string; tokenHost: string; audience?: string } | undefined + private tokenRequestPending = false + private authOptions: + | { + method: AuthMethod.CLIENT_CREDENTIALS + clientId: string + clientSecret: string + tokenHost: string + audience?: string + } + | { method: AuthMethod.BEARER_TOKEN; bearerToken: string } + | undefined + private tokenRefreshTimeout: NodeJS.Timeout | undefined async init(options: HTTPSendOptions): Promise { - if (options.oauthClientId && options.oauthClientSecret && options.oauthTokenHost) { + if (options.bearerToken) { this.authOptions = { + method: AuthMethod.BEARER_TOKEN, + bearerToken: options.bearerToken, + } + } else if (options.oauthClientId && options.oauthClientSecret && options.oauthTokenHost) { + this.authOptions = { + method: AuthMethod.CLIENT_CREDENTIALS, clientId: options.oauthClientId, clientSecret: options.oauthClientSecret, audience: options.oauthAudience, tokenHost: options.oauthTokenHost, } - const promise = this.requestAccessToken() - promise.catch( - (e) => { - this.emit('error', 'AuthenticatedHTTPSendDevice', e) - } - // retry - ) - this.tokenPromise = promise + this.requestAccessToken() } return super.init(options) } - async requestAccessToken(): Promise { - if (!this.authOptions) { - throw Error('authOptions missing') + async terminate() { + this.clearTokenRefreshTimeout() + return super.terminate() + } + + private requestAccessToken(): void { + if (this.tokenRequestPending) return + this.clearTokenRefreshTimeout() + this.tokenRequestPending = true + const promise = this.makeAccessTokenRequest() + promise + .then((accessToken) => { + this.emit('debug', `token received`) + const expiresIn = accessToken.token.expires_in + if (typeof expiresIn === 'number') { + this.scheduleTokenRefresh(expiresIn) + } + }) + .catch((e) => { + this.emit('error', 'AuthenticatedHTTPSendDevice', e) + setTimeout(() => this.requestAccessToken(), TOKEN_REQUEST_RETRY_TIMEOUT_MS) + }) + .finally(() => { + this.tokenRequestPending = false + }) + this.tokenPromise = promise + } + + private clearTokenRefreshTimeout() { + if (this.tokenRefreshTimeout) { + clearTimeout(this.tokenRefreshTimeout) } + } + + private scheduleTokenRefresh(expiresInSec: number) { + const timeoutMs = (expiresInSec - TOKEN_EXPIRATION_WINDOW_SEC) * 1000 + this.emit('debug', `token refresh scheduled in ${timeoutMs}`) + this.tokenRefreshTimeout = setTimeout(() => this.refreshAccessToken(), timeoutMs) + } + + private refreshAccessToken(): void { + this.emit('debug', `token refresh`) + this.requestAccessToken() + this.tokenRefreshTimeout = undefined + } + + private async makeAccessTokenRequest(): Promise { + if (!this.authOptions || this.authOptions.method !== AuthMethod.CLIENT_CREDENTIALS) { + throw Error('authOptions missing or incorrect') + } + this.emit('debug', 'token request') const token = await new ClientCredentials({ client: { id: this.authOptions.clientId, @@ -38,23 +102,23 @@ export class AuthenticatedHTTPSendDevice extends HTTPSendDevice { auth: { tokenHost: this.authOptions.tokenHost, }, - }).getToken({}) + }).getToken({ + audience: this.authOptions.audience, + }) return token } async sendCommand({ tlObjId, context, command }: HttpSendDeviceCommand): Promise { - if (this.options) { - const bearerToken = await this.tokenPromise + if (this.authOptions) { + const bearerToken = + this.authOptions.method === AuthMethod.BEARER_TOKEN ? this.authOptions.bearerToken : await this.tokenPromise if (bearerToken) { - if (bearerToken.expired()) { - // todo: this should happen only once - this.tokenPromise = bearerToken.refresh() - } + const bearerHeader = `Bearer ${typeof bearerToken === 'string' ? bearerToken : bearerToken.token.access_token}` command = { ...command, content: { ...command.content, - headers: { ...command.content.headers, ['Authorization']: `Bearer ${bearerToken.token.access_token}` }, + headers: { ...command.content.headers, ['Authorization']: bearerHeader }, }, } }