Skip to content

Commit

Permalink
Merge branch 'pr/ianshade/329' into release51
Browse files Browse the repository at this point in the history
  • Loading branch information
jstarpl committed Jun 18, 2024
2 parents ac49d8c + 76592f2 commit 1517e68
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface HTTPSendOptions {
* URLs not to use a proxy for (E.G. github.com)
*/
noProxy?: string[]
oauthTokenHost?: string
oauthTokenPath?: string
oauthClientId?: string
oauthClientSecret?: string
oauthAudience?: string
bearerToken?: string
}

export type SomeMappingHttpSend = Record<string, never>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export enum VizMSEActions {
ClearAllEngines = 'clearAllEngines'
}
export interface VizMSEActionExecutionResults {
vizReset: (payload: VizResetPayload) => void,
purgeRundown: () => void,
activate: (payload: ActivatePayload) => void,
standDown: () => void,
Expand Down
2 changes: 2 additions & 0 deletions packages/timeline-state-resolver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"p-all": "^3.0.0",
"p-queue": "^6.6.2",
"p-timeout": "^3.2.0",
"simple-oauth2": "^5.0.0",
"sprintf-js": "^1.1.3",
"superfly-timeline": "^9.0.0",
"threadedclass": "^1.2.1",
Expand All @@ -131,6 +132,7 @@
]
},
"devDependencies": {
"@types/simple-oauth2": "^5.0.7",
"i18next-conv": "^13.1.1",
"i18next-parser": "^6.6.0",
"json-schema-ref-parser": "^9.0.9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@
"description": "URLs not to use a proxy for (E.G. github.com)",
"ui:description": "URLs that shouldn't be accessed via a proxy (E.G. github.com)",
"ui:title": "No proxy"
},
"oauthTokenHost": {
"type": "string",
"ui:title": "OAuth 2.0 Token Host",
"ui:description": "Base URL of the authorization server. 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 Token Path, OAuth 2.0 Audience, to exchange the credentials for a Bearer token that will be added to EVERY outgoing request made through this device. Example: 'https://auth.example.com'"
},
"oauthTokenPath": {
"type": "string",
"ui:title": "OAuth 2.0 Token Path",
"ui:description": "Path of the Token endpoint. Example: '/oauth/token' (default)"
},
"oauthClientId": {
"type": "string",
"ui:title": "OAuth 2.0 Client ID"
},
"oauthClientSecret": {
"type": "string",
"ui:title": "OAuth 2.0 Client Secret"
},
"oauthAudience": {
"type": "string",
"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": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 DEFAULT_TOKEN_PATH = '/oauth/token'

const enum AuthMethod {
BEARER_TOKEN,
CLIENT_CREDENTIALS,
}
type AuthOptions =
| {
method: AuthMethod.CLIENT_CREDENTIALS
clientId: string
clientSecret: string
tokenHost: string
tokenPath: string
audience?: string
}
| { method: AuthMethod.BEARER_TOKEN; bearerToken: string }
| undefined

export class AuthenticatedHTTPSendDevice extends HTTPSendDevice {
private tokenPromise: Promise<AccessToken> | undefined
private tokenRequestPending = false
private authOptions: AuthOptions
private tokenRefreshTimeout: NodeJS.Timeout | undefined

async init(options: HTTPSendOptions): Promise<boolean> {
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,
tokenPath: options.oauthTokenPath ?? DEFAULT_TOKEN_PATH,
}
this.requestAccessToken()
}
return super.init(options)
}

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.context.logger.debug(`token received`)
const expiresIn = accessToken.token.expires_in
if (typeof expiresIn === 'number') {
this.scheduleTokenRefresh(expiresIn)
}
})
.catch((e) => {
this.context.logger.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.context.logger.debug(`token refresh scheduled in ${timeoutMs}`)
this.tokenRefreshTimeout = setTimeout(() => this.refreshAccessToken(), timeoutMs)
}

private refreshAccessToken(): void {
this.context.logger.debug(`token refresh`)
this.requestAccessToken()
this.tokenRefreshTimeout = undefined
}

private async makeAccessTokenRequest(): Promise<AccessToken> {
if (!this.authOptions || this.authOptions.method !== AuthMethod.CLIENT_CREDENTIALS) {
throw Error('authOptions missing or incorrect')
}
this.context.logger.debug('debug', 'token request')
const token = await new ClientCredentials({
client: {
id: this.authOptions.clientId,
secret: this.authOptions.clientSecret,
},
auth: {
tokenHost: this.authOptions.tokenHost,
tokenPath: this.authOptions.tokenPath,
},
}).getToken({
audience: this.authOptions.audience,
})
return token
}

async sendCommand({ timelineObjId, context, command }: HttpSendDeviceCommand): Promise<void> {
if (this.authOptions) {
const bearerToken =
this.authOptions.method === AuthMethod.BEARER_TOKEN ? this.authOptions.bearerToken : await this.tokenPromise
if (bearerToken) {
const bearerHeader = `Bearer ${typeof bearerToken === 'string' ? bearerToken : bearerToken.token.access_token}`
command = {
...command,
content: {
...command.content,
headers: { ...command.content.headers, ['Authorization']: bearerHeader },
},
}
}
}
return super.sendCommand({ timelineObjId, context, command })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export interface HttpSendDeviceCommand extends CommandWithContext {

export class HTTPSendDevice extends Device<HTTPSendOptions, HttpSendDeviceState, HttpSendDeviceCommand> {
/** Setup in init */
private options!: HTTPSendOptions
protected options!: HTTPSendOptions
/** Maps layers -> sent command-hashes */
private trackedState = new Map<string, string>()
private readonly cacheable = new CacheableLookup()
private _terminated = false
protected trackedState = new Map<string, string>()
protected readonly cacheable = new CacheableLookup()
protected _terminated = false

async init(options: HTTPSendOptions): Promise<boolean> {
this.options = options
Expand Down
4 changes: 2 additions & 2 deletions packages/timeline-state-resolver/src/service/devices.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { OscDevice } from '../integrations/osc'
import { DeviceType } from 'timeline-state-resolver-types'
import { Device, DeviceContextAPI } from './device'
import { HTTPSendDevice } from '../integrations/httpSend'
import { AuthenticatedHTTPSendDevice } from '../integrations/httpSend/AuthenticatedHTTPSendDevice'
import { ShotokuDevice } from '../integrations/shotoku'
import { HTTPWatcherDevice } from '../integrations/httpWatcher'
import { AbstractDevice } from '../integrations/abstract'
Expand Down Expand Up @@ -51,7 +51,7 @@ export const DevicesDict: Record<ImplementedServiceDeviceTypes, DeviceEntry> = {
executionMode: () => 'salvo',
},
[DeviceType.HTTPSEND]: {
deviceClass: HTTPSendDevice,
deviceClass: AuthenticatedHTTPSendDevice,
canConnect: false,
deviceName: (deviceId: string) => 'HTTPSend ' + deviceId,
executionMode: () => 'sequential', // todo - config?
Expand Down
Loading

0 comments on commit 1517e68

Please sign in to comment.