Skip to content

Commit

Permalink
feat(EAV-243): add OAuth (Client Credentials grant) and Bearer Token …
Browse files Browse the repository at this point in the history
…to HTTPSend
  • Loading branch information
ianshade committed May 24, 2024
1 parent 0ad6b7a commit 8fef807
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface HTTPSendOptions {
oauthClientId?: string
oauthClientSecret?: string
oauthAudience?: string
bearerToken?: string
}
export interface HTTPSendCommandContent {
type: TimelineContentTypeHTTP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccessToken> | 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<boolean> {
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<AccessToken> {
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<AccessToken> {
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,
Expand All @@ -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<void> {
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 },
},
}
}
Expand Down

0 comments on commit 8fef807

Please sign in to comment.