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