UNPKG

@ydbjs/auth

Version:

Authentication providers for YDB: static credentials, tokens, anonymous, and cloud metadata. Integrates with the core driver for secure access.

156 lines 7.63 kB
import * as tls from 'node:tls'; import { anyUnpack } from '@bufbuild/protobuf/wkt'; import { credentials } from '@grpc/grpc-js'; import { AuthServiceDefinition, LoginResultSchema } from '@ydbjs/api/auth'; import { StatusIds_StatusCode } from '@ydbjs/api/operation'; import { loggers } from '@ydbjs/debug'; import { YDBError } from '@ydbjs/error'; import { defaultRetryConfig, retry } from '@ydbjs/retry'; import { ClientError, Status, createChannel, createClient } from 'nice-grpc'; import { CredentialsProvider } from './index.js'; let debug = loggers.auth.extend('static'); // Token refresh strategy configuration const ACQUIRE_TOKEN_TIMEOUT_MS = 5_000; // 5 seconds timeout for token acquisition const HARD_EXPIRY_BUFFER_SECONDS = 30; // Hard limit - must refresh const SOFT_EXPIRY_BUFFER_SECONDS = 120; // Soft limit - start background refresh const BACKGROUND_REFRESH_TIMEOUT_MS = 30_000; // 30 seconds timeout for background refresh /** * A credentials provider that uses static username and password to authenticate. * It fetches and caches a token from the specified authentication service. * * @extends CredentialsProvider */ export class StaticCredentialsProvider extends CredentialsProvider { #client; #username; #password; #token = undefined; #promise = undefined; #backgroundRefreshPromise = undefined; constructor({ username, password }, endpoint, secureOptions, channelOptions) { super(); this.#username = username; this.#password = password; debug.log('creating static credentials provider for user: %s, endpoint: %s', username, endpoint); let cs = new URL(endpoint); if (['unix:', 'http:', 'https:', 'grpc:', 'grpcs:'].includes(cs.protocol) === false) { throw new Error('Invalid connection string protocol. Must be one of unix, grpc, grpcs, http, https'); } let address = cs.host; // For unix sockets, keep the full URL if (cs.protocol === 'unix:') { address = `${cs.protocol}//${cs.host}${cs.pathname}`; } let channelCredentials = secureOptions ? credentials.createFromSecureContext(tls.createSecureContext(secureOptions)) : credentials.createInsecure(); this.#client = createClient(AuthServiceDefinition, createChannel(address, channelCredentials, channelOptions)); } /** * Returns the token from the credentials. * @param force - if true, forces a new token to be fetched * @param signal - an optional AbortSignal to cancel the request. Defaults to a timeout of 5 seconds. * @returns the token */ async getToken(force = false, signal = AbortSignal.timeout(ACQUIRE_TOKEN_TIMEOUT_MS)) { let currentTimeSeconds = Date.now() / 1000; // If token is still valid (hard buffer), return it if (!force && this.#token && this.#token.exp > currentTimeSeconds + HARD_EXPIRY_BUFFER_SECONDS) { let expiresInSeconds = this.#token.exp - currentTimeSeconds; debug.log('returning cached token, expires in %d seconds', Math.floor(expiresInSeconds)); // Start background refresh if approaching soft expiry if (this.#token.exp <= currentTimeSeconds + SOFT_EXPIRY_BUFFER_SECONDS && !this.#promise && !this.#backgroundRefreshPromise) { debug.log('token approaching soft expiry, starting background refresh'); // Fire and forget background refresh with timeout this.#backgroundRefreshPromise = this.#refreshTokenInBackground(signal).finally(() => { this.#backgroundRefreshPromise = undefined; }); } return this.#token.value; } if (this.#promise) { debug.log('token refresh already in progress, waiting for result'); return this.#promise; } debug.log('fetching new token (force=%s, expired=%s)', force, this.#token ? 'true' : 'false'); return this.#refreshToken(signal); } /** * Refreshes the token in the background without blocking current requests * @param signal - an optional AbortSignal to cancel the request */ async #refreshTokenInBackground(signal) { if (this.#promise || this.#backgroundRefreshPromise) { debug.log('background refresh skipped, already refreshing'); return; // Already refreshing (either sync or background) } debug.log('starting background token refresh'); // Combine user signal with timeout signal let combinedSignal = AbortSignal.any([ signal, AbortSignal.timeout(BACKGROUND_REFRESH_TIMEOUT_MS), ]); void this.#refreshToken(combinedSignal); } /** * Refreshes the authentication token from the service * @param signal - an optional AbortSignal to cancel the request * @returns the new token value */ async #refreshToken(signal) { this.#promise = retry({ ...defaultRetryConfig, signal, idempotent: true, onRetry: (ctx) => { debug.log('retry attempt #%d after error: %s', ctx.attempt, ctx.error); }, }, async () => { debug.log('attempting login with user: %s', this.#username); let response = await this.#client.login({ user: this.#username, password: this.#password }, { signal }); if (!response.operation) { throw new ClientError(AuthServiceDefinition.login.path, Status.UNKNOWN, 'No operation in response'); } if (response.operation.status !== StatusIds_StatusCode.SUCCESS) { throw new YDBError(response.operation.status, response.operation.issues); } let result = anyUnpack(response.operation.result, LoginResultSchema); if (!result) { throw new ClientError(AuthServiceDefinition.login.path, Status.UNKNOWN, 'No result in operation'); } debug.log('login successful, parsing JWT token'); // The result.token is a JWT in the format header.payload.signature. // We attempt to decode the payload to extract token metadata (aud, exp, iat, sub). // If the token is not in the expected format, we fallback to default values. let [header, payload, signature] = result.token.split('.'); if (header && payload && signature) { let decodedPayload = JSON.parse(Buffer.from(payload, 'base64').toString()); this.#token = { value: result.token, ...decodedPayload, }; debug.log('token parsed successfully, expires at %s', new Date(decodedPayload.exp * 1000).toISOString()); } else { debug.log('token not in JWT format, using fallback metadata'); this.#token = { value: result.token, aud: [], exp: Math.floor(Date.now() / 1000) + 5 * 60, // fallback: 5 minutes from now iat: Math.floor(Date.now() / 1000), sub: '', }; debug.log('token created with fallback expiry: %s', new Date(this.#token.exp * 1000).toISOString()); } return this.#token.value; }).finally(() => { this.#promise = undefined; }); return this.#promise; } } //# sourceMappingURL=static.js.map