UNPKG

@ydbjs/auth

Version:

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

245 lines 12 kB
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value; }; var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); }; })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }); import * as tls from 'node:tls'; import { channel as dc, tracingChannel } from 'node:diagnostics_channel'; 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 { linkSignals } from '@ydbjs/abortable'; import { ClientError, Status, createChannel, createClient } from 'nice-grpc'; import { CredentialsProvider } from './index.js'; let debug = loggers.auth.extend('static'); let authTokenFetchCh = tracingChannel('tracing:ydb:auth.token.fetch'); // 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; // Tracks whether the current token has already triggered a `token.expired` // event so concurrent getToken() calls don't fan out into N events for the // same incident. Reset on every successful refresh. #expiredReported = false; 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; } // Fire `token.expired` at most once per incident: only the first caller // who observes the cached token past its hard buffer publishes; the next // successful refresh resets the flag for the new token. if (this.#token && this.#token.exp <= currentTimeSeconds + HARD_EXPIRY_BUFFER_SECONDS && !this.#expiredReported) { this.#expiredReported = true; // `stalenessMs` measures how long we've been past the hard buffer // (== exp + buffer). Negative values mean we're still inside the // buffer but planning a forced refresh — normalize to 0. let stalenessMs = Math.max(0, Date.now() - (this.#token.exp - HARD_EXPIRY_BUFFER_SECONDS) * 1000); dc('ydb:auth.token.expired').publish({ provider: 'static', stalenessMs }); } 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) { const env_1 = { stack: [], error: void 0, hasError: false }; try { 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'); const linkedSignal = __addDisposableResource(env_1, linkSignals(signal, AbortSignal.timeout(BACKGROUND_REFRESH_TIMEOUT_MS)), false); await this.#refreshToken(linkedSignal.signal).catch(() => { }); } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } } /** * Refreshes the authentication token from the service * @param signal - an optional AbortSignal to cancel the request * @returns the new token value */ async #refreshToken(signal) { let retryConfig = { ...defaultRetryConfig, signal, idempotent: true, onRetry: (ctx) => { debug.log('retry attempt #%d after error: %s', ctx.attempt, ctx.error); }, }; this.#promise = authTokenFetchCh .tracePromise(() => retry(retryConfig, (attemptSignal) => this.#loginAttempt(attemptSignal)), { provider: 'static' }) .catch((error) => { dc('ydb:auth.provider.failed').publish({ provider: 'static', error }); throw error; }) .finally(() => { this.#promise = undefined; }); return this.#promise; } async #loginAttempt(signal) { 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()); } // Allow the next expiration incident to be reported. this.#expiredReported = false; dc('ydb:auth.token.refreshed').publish({ provider: 'static', expiresAt: this.#token.exp * 1000, }); return this.#token.value; } } //# sourceMappingURL=static.js.map