@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
JavaScript
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