All files / packages/auth/src metadata.ts

92% Statements 46/50
92% Branches 23/25
100% Functions 9/9
92% Lines 46/50

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167              3x       3x                                           8x   8x 8x   8x       8x                   8x 8x 1x     8x 1x     8x                                   9x         9x 1x 1x 1x     9x 2x 2x     7x   7x 5x         3x       7x 7x 10x       2x 2x     7x     7x       10x 10x         10x   10x 5x     5x 5x     5x       5x         5x         10x     10x   10x       10x      
import { channel as dc, tracingChannel } from 'node:diagnostics_channel'
 
import { loggers } from '@ydbjs/debug'
import { type RetryConfig, retry } from '@ydbjs/retry'
import { backoff } from '@ydbjs/retry/strategy'
import { CredentialsProvider } from './index.js'
 
let authTokenFetchCh = tracingChannel<{ provider: 'metadata' }, { provider: 'metadata' }>(
	'tracing:ydb:auth.token.fetch'
)
 
let dbg = loggers.auth.extend('metadata')
 
export type MetadataCredentialsToken = {
	value: string
	expired_at: number
}
 
export type MetadataCredentials = {
	endpoint?: string
	flavor?: string
}
 
/**
 * A credentials provider that retrieves tokens from a metadata service.
 *
 * This class extends the `CredentialsProvider` class and implements the `getToken` method
 * to fetch tokens from a specified metadata endpoint. It supports optional retry logic
 * and allows customization of the metadata flavor and endpoint.
 *
 * @extends CredentialsProvider
 */
export class MetadataCredentialsProvider extends CredentialsProvider {
	#promise: Promise<string> | null = null
 
	#token: MetadataCredentialsToken | null = null
	#flavor: string = 'Google'
	#endpoint: string =
		'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'
 
	// See StaticCredentialsProvider — fire `token.expired` at most once per
	// incident; reset on every successful refresh.
	#expiredReported = false
 
	/**
	 * Creates an instance of `MetadataCredentialsProvider`.
	 *
	 * @param credentials - An optional object containing metadata credentials.
	 * @param credentials.flavor - The metadata flavor (default: 'Google').
	 * @param credentials.endpoint - The metadata endpoint URL (default: 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token').
	 */
	constructor(credentials: MetadataCredentials = {}) {
		super()
		if (credentials.flavor) {
			this.#flavor = credentials.flavor
		}
 
		if (credentials.endpoint) {
			this.#endpoint = credentials.endpoint
		}
 
		dbg.log(
			'creating metadata credentials provider with flavor: %s, endpoint: %s',
			this.#flavor,
			this.#endpoint
		)
	}
 
	/**
	 * Retrieves an authentication token from the specified endpoint.
	 * If a valid token is already available and `force` is not true, it returns the cached token.
	 * Otherwise, it fetches a new token with optional retry logic based on the provided configuration.
	 *
	 * @param force - A flag indicating whether to force fetching a new token regardless of the existing one's validity.
	 * @param signal - An AbortSignal to cancel the operation if needed.
	 * @returns A promise resolving to the authentication token as a string.
	 * @throws Will throw an error if the token fetch fails, the response is not OK, or the content type is incorrect.
	 */
	async getToken(force?: boolean, signal?: AbortSignal): Promise<string> {
		Iif (!force && this.#token && this.#token.expired_at > Date.now()) {
			dbg.log('returning cached token, expires in %d ms', this.#token.expired_at - Date.now())
			return this.#token.value
		}
 
		if (this.#token && this.#token.expired_at <= Date.now() && !this.#expiredReported) {
			this.#expiredReported = true
			let stalenessMs = Date.now() - this.#token.expired_at
			dc('ydb:auth.token.expired').publish({ provider: 'metadata', stalenessMs })
		}
 
		if (this.#promise) {
			dbg.log('token fetch already in progress, waiting for result')
			return this.#promise
		}
 
		dbg.log('fetching new token from metadata service')
 
		let retryConfig: RetryConfig = {
			retry: (err) => err instanceof Error,
			signal,
			budget: 5,
			strategy: backoff(10, 1000),
			onRetry: (ctx) => {
				dbg.log('retrying token fetch, attempt %d, error: %O', ctx.attempt, ctx.error)
			},
		}
 
		this.#promise = authTokenFetchCh
			.tracePromise(
				() => retry(retryConfig, (attemptSignal) => this.#fetchTokenAttempt(attemptSignal)),
				{ provider: 'metadata' }
			)
			.catch((error) => {
				dc('ydb:auth.provider.failed').publish({ provider: 'metadata', error })
				throw error
			})
			.finally(() => {
				this.#promise = null
			})
 
		return this.#promise
	}
 
	async #fetchTokenAttempt(signal: AbortSignal): Promise<string> {
		dbg.log('attempting to fetch token from %s', this.#endpoint)
		let response = await fetch(this.#endpoint, {
			headers: { 'Metadata-Flavor': this.#flavor },
			signal,
		})
 
		dbg.log('%s %s %s', this.#endpoint, response.status, response.headers.get('Content-Type'))
 
		if (!response.ok) {
			let error = new Error(
				`Failed to fetch token: ${response.status} ${response.statusText}`
			)
			dbg.log('error fetching token: %O', error)
			throw error
		}
 
		let token = JSON.parse(await response.text()) as {
			access_token?: string
			expires_in?: number
		}
		Iif (!token.access_token) {
			dbg.log('missing access token in response, response: %O', token)
			throw new Error('No access token exists in response')
		}
 
		this.#token = {
			value: token.access_token,
			expired_at: Date.now() + (token.expires_in ?? 3600) * 1000,
		}
 
		dbg.log('token fetched successfully, expires in %d seconds', token.expires_in ?? 3600)
 
		// Allow the next expiration incident to be reported.
		this.#expiredReported = false
 
		dc('ydb:auth.token.refreshed').publish({
			provider: 'metadata',
			expiresAt: this.#token.expired_at,
		})
		return this.#token.value
	}
}