@segment/analytics-node
Version:
https://www.npmjs.com/package/@segment/analytics-node
328 lines (290 loc) • 9.96 kB
text/typescript
import { uuid } from './uuid'
import { HTTPClient, HTTPClientRequest, HTTPResponse } from './http-client'
import { SignJWT, importPKCS8 } from 'jose'
import { backoff, sleep } from '@segment/analytics-core'
import { Emitter } from '@segment/analytics-generic-utils'
import type {
AccessToken,
OAuthSettings,
TokenManager as ITokenManager,
} from './types'
const isAccessToken = (thing: unknown): thing is AccessToken => {
return Boolean(
thing &&
typeof thing === 'object' &&
'access_token' in thing &&
'expires_in' in thing &&
typeof thing.access_token === 'string' &&
typeof thing.expires_in === 'number'
)
}
const isValidCustomResponse = (
response: HTTPResponse
): response is HTTPResponse & Required<Pick<HTTPResponse, 'text'>> => {
return typeof response.text === 'function'
}
function convertHeaders(
headers: HTTPResponse['headers']
): Record<string, string> {
const lowercaseHeaders: Record<string, string> = {}
if (!headers) return {}
if (isHeaders(headers)) {
for (const [name, value] of headers.entries()) {
lowercaseHeaders[name.toLowerCase()] = value
}
return lowercaseHeaders
}
for (const [name, value] of Object.entries(headers)) {
lowercaseHeaders[name.toLowerCase()] = value as string
}
return lowercaseHeaders
}
function isHeaders(thing: unknown): thing is HTTPResponse['headers'] {
if (
typeof thing === 'object' &&
thing !== null &&
'entries' in Object(thing) &&
typeof Object(thing).entries === 'function'
) {
return true
}
return false
}
export interface TokenManagerSettings extends OAuthSettings {
httpClient: HTTPClient
maxRetries: number
}
export class TokenManager implements ITokenManager {
private alg = 'RS256' as const
private grantType = 'client_credentials' as const
private clientAssertionType =
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' as const
private clientId: string
private clientKey: string
private keyId: string
private scope: string
private authServer: string
private httpClient: HTTPClient
private maxRetries: number
private clockSkewInSeconds = 0
private accessToken?: AccessToken
private tokenEmitter = new Emitter<{
access_token: [{ token: AccessToken } | { error: unknown }]
}>()
private retryCount: number
private pollerTimer?: ReturnType<typeof setTimeout>
constructor(props: TokenManagerSettings) {
this.keyId = props.keyId
this.clientId = props.clientId
this.clientKey = props.clientKey
this.authServer = props.authServer ?? 'https://oauth2.segment.io'
this.scope = props.scope ?? 'tracking_api:write'
this.httpClient = props.httpClient
this.maxRetries = props.maxRetries
this.tokenEmitter.on('access_token', (event) => {
if ('token' in event) {
this.accessToken = event.token
}
})
this.retryCount = 0
}
stopPoller() {
clearTimeout(this.pollerTimer)
}
async pollerLoop() {
let timeUntilRefreshInMs = 25
let response: HTTPResponse
try {
response = await this.requestAccessToken()
} catch (err) {
// Error without a status code - likely networking, retry
return this.handleTransientError({ error: err })
}
if (!isValidCustomResponse(response)) {
return this.handleInvalidCustomResponse()
}
const headers = convertHeaders(response.headers)
if (headers['date']) {
this.updateClockSkew(Date.parse(headers['date']))
}
// Handle status codes!
if (response.status === 200) {
try {
const body = await response.text()
const token = JSON.parse(body)
if (!isAccessToken(token)) {
throw new Error(
'Response did not contain a valid access_token and expires_in'
)
}
// Success, we have a token!
token.expires_at = Math.round(Date.now() / 1000) + token.expires_in
this.tokenEmitter.emit('access_token', { token })
// Reset our failure count
this.retryCount = 0
// Refresh the token after half the expiry time passes
timeUntilRefreshInMs = (token.expires_in / 2) * 1000
return this.queueNextPoll(timeUntilRefreshInMs)
} catch (err) {
// Something went really wrong with the body, lets surface an error and try again?
return this.handleTransientError({ error: err, forceEmitError: true })
}
} else if (response.status === 429) {
// Rate limited, wait for the reset time
return await this.handleRateLimited(
response,
headers,
timeUntilRefreshInMs
)
} else if ([400, 401, 415].includes(response.status)) {
// Unrecoverable errors, stops the poller
return this.handleUnrecoverableErrors(response)
} else {
return this.handleTransientError({
error: new Error(`[${response.status}] ${response.statusText}`),
})
}
}
private handleTransientError({
error,
forceEmitError,
}: {
error: unknown
forceEmitError?: boolean
}) {
this.incrementRetries({ error, forceEmitError })
const timeUntilRefreshInMs = backoff({
attempt: this.retryCount,
minTimeout: 25,
maxTimeout: 1000,
})
this.queueNextPoll(timeUntilRefreshInMs)
}
private handleInvalidCustomResponse() {
this.tokenEmitter.emit('access_token', {
error: new Error('HTTPClient does not implement response.text method'),
})
}
private async handleRateLimited(
response: HTTPResponse,
headers: Record<string, string>,
timeUntilRefreshInMs: number
) {
this.incrementRetries({
error: new Error(`[${response.status}] ${response.statusText}`),
})
if (headers['x-ratelimit-reset']) {
const rateLimitResetTimestamp = parseInt(headers['x-ratelimit-reset'], 10)
if (isFinite(rateLimitResetTimestamp)) {
timeUntilRefreshInMs =
rateLimitResetTimestamp - Date.now() + this.clockSkewInSeconds * 1000
} else {
timeUntilRefreshInMs = 5 * 1000
}
// We want subsequent calls to get_token to be able to interrupt our
// Timeout when it's waiting for e.g. a long normal expiration, but
// not when we're waiting for a rate limit reset. Sleep instead.
await sleep(timeUntilRefreshInMs)
timeUntilRefreshInMs = 0
}
this.queueNextPoll(timeUntilRefreshInMs)
}
private handleUnrecoverableErrors(response: HTTPResponse) {
this.retryCount = 0
this.tokenEmitter.emit('access_token', {
error: new Error(`[${response.status}] ${response.statusText}`),
})
this.stopPoller()
}
private updateClockSkew(dateInMs: number) {
this.clockSkewInSeconds = (Date.now() - dateInMs) / 1000
}
private incrementRetries({
error,
forceEmitError,
}: {
error: unknown
forceEmitError?: boolean
}) {
this.retryCount++
if (forceEmitError || this.retryCount % this.maxRetries === 0) {
this.retryCount = 0
this.tokenEmitter.emit('access_token', { error: error })
}
}
private queueNextPoll(timeUntilRefreshInMs: number) {
this.pollerTimer = setTimeout(() => this.pollerLoop(), timeUntilRefreshInMs)
if (this.pollerTimer.unref) {
this.pollerTimer.unref()
}
}
/**
* Solely responsible for building the HTTP request and calling the token service.
*/
private async requestAccessToken(): Promise<HTTPResponse> {
// Set issued at time to 5 seconds in the past to account for clock skew
const ISSUED_AT_BUFFER_IN_SECONDS = 5
const MAX_EXPIRY_IN_SECONDS = 60
// Final expiry time takes into account the issued at time, so need to subtract IAT buffer
const EXPIRY_IN_SECONDS =
MAX_EXPIRY_IN_SECONDS - ISSUED_AT_BUFFER_IN_SECONDS
const jti = uuid()
const currentUTCInSeconds =
Math.round(Date.now() / 1000) - this.clockSkewInSeconds
const jwtBody = {
iss: this.clientId,
sub: this.clientId,
aud: this.authServer,
iat: currentUTCInSeconds - ISSUED_AT_BUFFER_IN_SECONDS,
exp: currentUTCInSeconds + EXPIRY_IN_SECONDS,
jti,
}
const key = await importPKCS8(this.clientKey, 'RS256')
const signedJwt = await new SignJWT(jwtBody)
.setProtectedHeader({ alg: this.alg, kid: this.keyId, typ: 'JWT' })
.sign(key)
const requestBody = `grant_type=${this.grantType}&client_assertion_type=${this.clientAssertionType}&client_assertion=${signedJwt}&scope=${this.scope}`
const accessTokenEndpoint = `${this.authServer}/token`
const requestOptions: HTTPClientRequest = {
method: 'POST',
url: accessTokenEndpoint,
body: requestBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
httpRequestTimeout: 10000,
}
return this.httpClient.makeRequest(requestOptions)
}
async getAccessToken(): Promise<AccessToken> {
// Use the cached token if it is still valid, otherwise wait for a new token.
if (this.isValidToken(this.accessToken)) {
return this.accessToken
}
// stop poller first in order to make sure that it's not sleeping if we need a token immediately
// Otherwise it could be hours before the expiration time passes normally
this.stopPoller()
// startPoller needs to be called somewhere, either lazily when a token is first requested, or at instantiation.
// Doing it lazily currently
this.pollerLoop().catch(() => {})
return new Promise((resolve, reject) => {
this.tokenEmitter.once('access_token', (event) => {
if ('token' in event) {
resolve(event.token)
} else {
reject(event.error)
}
})
})
}
clearToken() {
this.accessToken = undefined
}
isValidToken(token?: AccessToken): token is AccessToken {
return (
typeof token !== 'undefined' &&
token !== null &&
token.expires_in < Date.now() / 1000
)
}
}