diff --git a/CHANGELOG.md b/CHANGELOG.md index c76a1c2c..7b6c3df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ -# UNRELEASED (Common, Node.js, Web) +# 1.1.0 (Common, Node.js, Web) -## Bug fixes +## New features +- Added an option to override the credentials for a particular `query`/`command`/`exec`/`insert` request via the `BaseQueryParams.auth` setting; when set, the credentials will be taken from there instead of the username/password provided during the client instantiation. - Allow overriding `session_id` per query ([@holi0317](https://github.com/Holi0317), [#271](https://github.com/ClickHouse/clickhouse-js/issues/271)). # 1.0.2 (Common, Node.js, Web) diff --git a/packages/client-common/__tests__/integration/auth.test.ts b/packages/client-common/__tests__/integration/auth.test.ts index 3a734cc3..0aa2c8cc 100644 --- a/packages/client-common/__tests__/integration/auth.test.ts +++ b/packages/client-common/__tests__/integration/auth.test.ts @@ -1,18 +1,21 @@ import { type ClickHouseClient } from '@clickhouse/client-common' -import { createTestClient } from '../utils' +import { createSimpleTable } from '@test/fixtures/simple_table' +import { getAuthFromEnv } from '@test/utils/env' +import { createTestClient, guid } from '../utils' describe('authentication', () => { let client: ClickHouseClient - afterEach(async () => { - await client.close() - }) - - it('provides authentication error details', async () => { + beforeEach(() => { client = createTestClient({ username: 'gibberish', password: 'gibberish', }) + }) + afterEach(async () => { + await client.close() + }) + it('provides authentication error details', async () => { await expectAsync( client.query({ query: 'SELECT number FROM system.numbers LIMIT 3', @@ -25,4 +28,70 @@ describe('authentication', () => { }), ) }) + + describe('request auth override', () => { + let defaultClient: ClickHouseClient + beforeAll(() => { + defaultClient = createTestClient() + }) + afterAll(async () => { + await defaultClient.close() + }) + + let tableName: string + const values = [ + { + id: '1', + name: 'foo', + sku: [3, 4], + }, + ] + const auth = getAuthFromEnv() + + it('should with with insert and select', async () => { + tableName = `simple_table_${guid()}` + await createSimpleTable(defaultClient, tableName) + await client.insert({ + table: tableName, + format: 'JSONEachRow', + values, + auth, + }) + const rs = await client.query({ + query: `SELECT * FROM ${tableName} ORDER BY id ASC`, + format: 'JSONEachRow', + auth, + }) + expect(await rs.json()).toEqual(values) + }) + + it('should work with command and select', async () => { + tableName = `simple_table_${guid()}` + await createSimpleTable(defaultClient, tableName) + await client.command({ + query: `INSERT INTO ${tableName} VALUES (1, 'foo', [3, 4])`, + auth, + }) + const rs = await client.query({ + query: `SELECT * FROM ${tableName} ORDER BY id ASC`, + format: 'JSONEachRow', + auth, + }) + expect(await rs.json()).toEqual(values) + }) + + it('should work with exec', async () => { + const { stream } = await client.exec({ + query: 'SELECT 42, 144 FORMAT CSV', + auth, + }) + let result = '' + const textDecoder = new TextDecoder() + // @ts-expect-error - ReadableStream (Web) or Stream.Readable (Node.js); same API. + for await (const chunk of stream) { + result += textDecoder.decode(chunk, { stream: true }) + } + expect(result).toEqual('42,144\n') + }) + }) }) diff --git a/packages/client-common/__tests__/utils/client.ts b/packages/client-common/__tests__/utils/client.ts index f3e906d7..a29bd5dd 100644 --- a/packages/client-common/__tests__/utils/client.ts +++ b/packages/client-common/__tests__/utils/client.ts @@ -4,7 +4,7 @@ import type { ClickHouseClient, ClickHouseSettings, } from '@clickhouse/client-common' -import { getFromEnv } from './env' +import { EnvKeys, getFromEnv } from './env' import { guid } from './guid' import { getClickHouseTestEnvironment, @@ -55,8 +55,8 @@ export function createTestClient( } if (isCloudTestEnv()) { const cloudConfig: BaseClickHouseClientConfigOptions = { - url: `https://${getFromEnv('CLICKHOUSE_CLOUD_HOST')}:8443`, - password: getFromEnv('CLICKHOUSE_CLOUD_PASSWORD'), + url: `https://${getFromEnv(EnvKeys.host)}:8443`, + password: getFromEnv(EnvKeys.password), database: databaseName, ...logging, ...config, diff --git a/packages/client-common/__tests__/utils/env.ts b/packages/client-common/__tests__/utils/env.ts index 981897bc..c1d7955c 100644 --- a/packages/client-common/__tests__/utils/env.ts +++ b/packages/client-common/__tests__/utils/env.ts @@ -1,3 +1,9 @@ +export const EnvKeys = { + host: 'CLICKHOUSE_CLOUD_HOST', + username: 'CLICKHOUSE_CLOUD_USERNAME', + password: 'CLICKHOUSE_CLOUD_PASSWORD', +} + export function getFromEnv(key: string): string { const value = process.env[key] if (value === undefined) { @@ -5,3 +11,9 @@ export function getFromEnv(key: string): string { } return value } + +export function getAuthFromEnv() { + const username = process.env[EnvKeys.username] + const password = process.env[EnvKeys.password] + return { username: username ?? 'default', password: password ?? '' } +} diff --git a/packages/client-common/src/client.ts b/packages/client-common/src/client.ts index 191e01cf..f2305980 100644 --- a/packages/client-common/src/client.ts +++ b/packages/client-common/src/client.ts @@ -2,6 +2,7 @@ import type { BaseClickHouseClientConfigOptions, ClickHouseSettings, Connection, + ConnectionParams, ConnExecResult, IsSame, LogWriter, @@ -23,11 +24,18 @@ export interface BaseQueryParams { /** AbortSignal instance to cancel a request in progress. */ abort_signal?: AbortSignal /** A specific `query_id` that will be sent with this request. - * If it is not set, a random identifier will be generated automatically by the client. */ + * If it is not set, a random identifier will be generated automatically by the client. */ query_id?: string /** A specific `session_id` for this query * If it is not set, the client's session_id will be used. */ session_id?: string + /** When defined, overrides the credentials from the {@link BaseClickHouseClientConfigOptions.username} + * and {@link BaseClickHouseClientConfigOptions.password} settings for this particular request. + * @default undefined (no override) */ + auth?: { + username: string + password: string + } } export interface QueryParams extends BaseQueryParams { @@ -64,7 +72,7 @@ export type InsertResult = { * Indicates whether the INSERT statement was executed on the server. * Will be `false` if there was no data to insert. * For example: if {@link InsertParams.values} was an empty array, - * the client does not any requests to the server, and {@link executed} is false. + * the client does not send any requests to the server, and {@link executed} is false. */ executed: boolean /** @@ -115,6 +123,7 @@ export interface InsertParams export class ClickHouseClient { private readonly clientClickHouseSettings: ClickHouseSettings + private readonly connectionParams: ConnectionParams private readonly connection: Connection private readonly makeResultSet: MakeResultSet private readonly valuesEncoder: ValuesEncoder @@ -132,13 +141,13 @@ export class ClickHouseClient { logger, config.impl.handle_specific_url_params ?? null, ) - const connectionParams = getConnectionParams(configWithURL, logger) - this.logWriter = connectionParams.log_writer - this.clientClickHouseSettings = connectionParams.clickhouse_settings + this.connectionParams = getConnectionParams(configWithURL, logger) + this.logWriter = this.connectionParams.log_writer + this.clientClickHouseSettings = this.connectionParams.clickhouse_settings this.sessionId = config.session_id this.connection = config.impl.make_connection( configWithURL, - connectionParams, + this.connectionParams, ) this.makeResultSet = config.impl.make_result_set this.valuesEncoder = config.impl.values_encoder @@ -250,10 +259,12 @@ export class ClickHouseClient { ...this.clientClickHouseSettings, ...params.clickhouse_settings, }, + session_id: this.sessionId, query_params: params.query_params, abort_signal: params.abort_signal, query_id: params.query_id, session_id: params.session_id ?? this.sessionId, + auth: params.auth, } } } diff --git a/packages/client-common/src/connection.ts b/packages/client-common/src/connection.ts index 3c7ce308..8b4243a7 100644 --- a/packages/client-common/src/connection.ts +++ b/packages/client-common/src/connection.ts @@ -29,6 +29,7 @@ export interface ConnBaseQueryParams { abort_signal?: AbortSignal session_id?: string query_id?: string + auth?: { username: string; password: string } } export interface ConnInsertParams extends ConnBaseQueryParams { diff --git a/packages/client-common/src/version.ts b/packages/client-common/src/version.ts index 287731b9..9e64831d 100644 --- a/packages/client-common/src/version.ts +++ b/packages/client-common/src/version.ts @@ -1 +1 @@ -export default '1.0.2' +export default '1.1.0' diff --git a/packages/client-node/__tests__/integration/node_logger_support.test.ts b/packages/client-node/__tests__/integration/node_logger_support.test.ts index 4687128e..701a4f52 100644 --- a/packages/client-node/__tests__/integration/node_logger_support.test.ts +++ b/packages/client-node/__tests__/integration/node_logger_support.test.ts @@ -1,10 +1,10 @@ -import { +import type { ClickHouseClient, - ClickHouseLogLevel, ErrorLogParams, Logger, LogParams, } from '@clickhouse/client-common' +import { ClickHouseLogLevel } from '@clickhouse/client-common' import { createTestClient } from '@test/utils' describe('[Node.js] logger support', () => { diff --git a/packages/client-node/__tests__/integration/node_query_format_types.test.ts b/packages/client-node/__tests__/integration/node_query_format_types.test.ts index 20408176..649005af 100644 --- a/packages/client-node/__tests__/integration/node_query_format_types.test.ts +++ b/packages/client-node/__tests__/integration/node_query_format_types.test.ts @@ -600,7 +600,7 @@ xdescribe('[Node.js] Query and ResultSet types', () => { const rs = await runQuery('JSON') // All possible JSON variants are now allowed - // $ExpectType unknown[] | Record | ResponseJSON + // FIXME: this line produces a ESLint error due to a different order (which is insignificant). -$ExpectType unknown[] | Record | ResponseJSON await rs.json() // IDE error here, different type order // $ExpectType Data[] | ResponseJSON | Record await rs.json() diff --git a/packages/client-node/__tests__/tls/tls.test.ts b/packages/client-node/__tests__/tls/tls.test.ts index c7b590c4..2b26b3dd 100644 --- a/packages/client-node/__tests__/tls/tls.test.ts +++ b/packages/client-node/__tests__/tls/tls.test.ts @@ -20,7 +20,7 @@ describe('[Node.js] TLS connection', () => { it('should work with basic TLS', async () => { client = createClient({ - host: 'https://server.clickhouseconnect.test:8443', + url: 'https://server.clickhouseconnect.test:8443', tls: { ca_cert, }, @@ -34,7 +34,7 @@ describe('[Node.js] TLS connection', () => { it('should work with mutual TLS', async () => { client = createClient({ - host: 'https://server.clickhouseconnect.test:8443', + url: 'https://server.clickhouseconnect.test:8443', username: 'cert_user', tls: { ca_cert, @@ -51,7 +51,7 @@ describe('[Node.js] TLS connection', () => { it('should fail when hostname does not match', async () => { client = createClient({ - host: 'https://localhost:8443', + url: 'https://localhost:8443', username: 'cert_user', tls: { ca_cert, @@ -75,7 +75,7 @@ describe('[Node.js] TLS connection', () => { it('should fail with invalid certificates', async () => { client = createClient({ - host: 'https://server.clickhouseconnect.test:8443', + url: 'https://server.clickhouseconnect.test:8443', username: 'cert_user', tls: { ca_cert, @@ -91,4 +91,49 @@ describe('[Node.js] TLS connection', () => { }), ).toBeRejectedWithError() }) + + // query only; the rest of the methods are tested in the auth.test.ts in the common package + describe('request auth override', () => { + it('should override the credentials with basic TLS', async () => { + client = createClient({ + url: 'https://server.clickhouseconnect.test:8443', + username: 'gibberish', + password: 'gibberish', + tls: { + ca_cert, + }, + }) + const resultSet = await client.query({ + query: 'SELECT number FROM system.numbers LIMIT 3', + format: 'CSV', + auth: { + username: 'default', + password: '', + }, + }) + expect(await resultSet.text()).toEqual('0\n1\n2\n') + }) + + it('should override the credentials with mutual TLS', async () => { + client = createClient({ + url: 'https://server.clickhouseconnect.test:8443', + username: 'gibberish', + password: 'gibberish', + tls: { + ca_cert, + cert, + key, + }, + }) + const resultSet = await client.query({ + query: 'SELECT number FROM system.numbers LIMIT 3', + format: 'CSV', + auth: { + username: 'cert_user', + password: '', + }, + }) + expect(await resultSet.text()).toEqual('0\n1\n2\n') + }) + }) }) diff --git a/packages/client-node/__tests__/utils/http_stubs.ts b/packages/client-node/__tests__/utils/http_stubs.ts index 2863b861..1ca9dce5 100644 --- a/packages/client-node/__tests__/utils/http_stubs.ts +++ b/packages/client-node/__tests__/utils/http_stubs.ts @@ -134,6 +134,6 @@ export class MyTestHttpConnection extends NodeBaseConnection { return {} as any } public getDefaultHeaders() { - return this.headers + return this.buildRequestHeaders() } } diff --git a/packages/client-node/src/connection/node_base_connection.ts b/packages/client-node/src/connection/node_base_connection.ts index 6606c050..5df78df2 100644 --- a/packages/client-node/src/connection/node_base_connection.ts +++ b/packages/client-node/src/connection/node_base_connection.ts @@ -1,4 +1,5 @@ import type { + BaseQueryParams, ClickHouseSummary, ConnBaseQueryParams, ConnCommandResult, @@ -52,6 +53,7 @@ export type TLSParams = export interface RequestParams { method: 'GET' | 'POST' url: URL + headers: Http.OutgoingHttpHeaders body?: string | Stream.Readable // provided by the user and wrapped around internally abort_signal: AbortSignal @@ -63,7 +65,10 @@ export interface RequestParams { export abstract class NodeBaseConnection implements Connection { - protected readonly headers: Http.OutgoingHttpHeaders + protected readonly defaultAuthHeader: string + protected readonly defaultHeaders: Http.OutgoingHttpHeaders + protected readonly additionalHTTPHeaders: Record + private readonly logger: LogWriter private readonly knownSockets = new WeakMap() private readonly idleSocketTTL: number @@ -72,13 +77,18 @@ export abstract class NodeBaseConnection protected readonly params: NodeConnectionParams, protected readonly agent: Http.Agent, ) { + this.additionalHTTPHeaders = params.http_headers ?? {} + this.defaultAuthHeader = `Basic ${Buffer.from( + `${params.username}:${params.password}`, + ).toString('base64')}` + this.defaultHeaders = { + ...this.additionalHTTPHeaders, + // KeepAlive agent for some reason does not set this on its own + Connection: this.params.keep_alive.enabled ? 'keep-alive' : 'close', + 'User-Agent': getUserAgent(this.params.application_id), + } this.logger = params.log_writer this.idleSocketTTL = params.keep_alive.idle_socket_ttl - this.headers = this.buildDefaultHeaders( - params.username, - params.password, - params.http_headers, - ) } async ping(): Promise { @@ -89,6 +99,7 @@ export abstract class NodeBaseConnection method: 'GET', url: transformUrl({ url: this.params.url, pathname: '/ping' }), abort_signal: abortController.signal, + headers: this.buildRequestHeaders(), }, 'Ping', ) @@ -135,6 +146,7 @@ export abstract class NodeBaseConnection body: params.query, abort_signal: controller.signal, decompress_response: decompressResponse, + headers: this.buildRequestHeaders(params), }, 'Query', ) @@ -183,6 +195,7 @@ export abstract class NodeBaseConnection abort_signal: controller.signal, compress_request: this.params.compression.compress_request, parse_summary: true, + headers: this.buildRequestHeaders(params), }, 'Insert', ) @@ -231,19 +244,15 @@ export abstract class NodeBaseConnection } } - protected buildDefaultHeaders( - username: string, - password: string, - additional_http_headers?: Record, + protected buildRequestHeaders( + params?: BaseQueryParams, ): Http.OutgoingHttpHeaders { return { - // KeepAlive agent for some reason does not set this on its own - Connection: this.params.keep_alive.enabled ? 'keep-alive' : 'close', - Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString( - 'base64', - )}`, - 'User-Agent': getUserAgent(this.params.application_id), - ...additional_http_headers, + ...this.defaultHeaders, + Authorization: + params?.auth !== undefined + ? `Basic ${Buffer.from(`${params.auth.username}:${params.auth.password}`).toString('base64')}` + : this.defaultAuthHeader, } } @@ -364,6 +373,7 @@ export abstract class NodeBaseConnection body: params.query, abort_signal: controller.signal, parse_summary: true, + headers: this.buildRequestHeaders(params), }, params.op, ) diff --git a/packages/client-node/src/connection/node_http_connection.ts b/packages/client-node/src/connection/node_http_connection.ts index 5a4dfaf6..58c7b5ed 100644 --- a/packages/client-node/src/connection/node_http_connection.ts +++ b/packages/client-node/src/connection/node_http_connection.ts @@ -16,16 +16,17 @@ export class NodeHttpConnection extends NodeBaseConnection { } protected createClientRequest(params: RequestParams): Http.ClientRequest { + const headers = withCompressionHeaders({ + headers: params.headers, + compress_request: params.compress_request, + decompress_response: params.decompress_response, + }) return Http.request(params.url, { method: params.method, agent: this.agent, timeout: this.params.request_timeout, - headers: withCompressionHeaders({ - headers: this.headers, - compress_request: params.compress_request, - decompress_response: params.decompress_response, - }), signal: params.abort_signal, + headers, }) } } diff --git a/packages/client-node/src/connection/node_https_connection.ts b/packages/client-node/src/connection/node_https_connection.ts index 2ab3d7b3..865ad546 100644 --- a/packages/client-node/src/connection/node_https_connection.ts +++ b/packages/client-node/src/connection/node_https_connection.ts @@ -1,3 +1,4 @@ +import type { BaseQueryParams } from '@clickhouse/client-common' import { withCompressionHeaders } from '@clickhouse/client-common' import type Http from 'http' import Https from 'https' @@ -19,38 +20,43 @@ export class NodeHttpsConnection extends NodeBaseConnection { super(params, agent) } - protected override buildDefaultHeaders( - username: string, - password: string, - additional_headers?: Record, + protected override buildRequestHeaders( + params?: BaseQueryParams, ): Http.OutgoingHttpHeaders { - if (this.params.tls?.type === 'Mutual') { - return { - 'X-ClickHouse-User': username, - 'X-ClickHouse-Key': password, - 'X-ClickHouse-SSL-Certificate-Auth': 'on', + if (this.params.tls !== undefined) { + const headers: Http.OutgoingHttpHeaders = { + ...this.defaultHeaders, + 'X-ClickHouse-User': params?.auth?.username ?? this.params.username, + 'X-ClickHouse-Key': params?.auth?.password ?? this.params.password, } - } - if (this.params.tls?.type === 'Basic') { - return { - 'X-ClickHouse-User': username, - 'X-ClickHouse-Key': password, + const tlsType = this.params.tls.type + switch (tlsType) { + case 'Basic': + return headers + case 'Mutual': + return { + ...headers, + 'X-ClickHouse-SSL-Certificate-Auth': 'on', + } + default: + throw new Error(`Unknown TLS type: ${tlsType}`) } } - return super.buildDefaultHeaders(username, password, additional_headers) + return super.buildRequestHeaders(params) } protected createClientRequest(params: RequestParams): Http.ClientRequest { + const headers = withCompressionHeaders({ + headers: params.headers, + compress_request: params.compress_request, + decompress_response: params.decompress_response, + }) return Https.request(params.url, { method: params.method, agent: this.agent, timeout: this.params.request_timeout, - headers: withCompressionHeaders({ - headers: this.headers, - compress_request: params.compress_request, - decompress_response: params.decompress_response, - }), signal: params.abort_signal, + headers, }) } } diff --git a/packages/client-node/src/version.ts b/packages/client-node/src/version.ts index 287731b9..9e64831d 100644 --- a/packages/client-node/src/version.ts +++ b/packages/client-node/src/version.ts @@ -1 +1 @@ -export default '1.0.2' +export default '1.1.0' diff --git a/packages/client-web/src/connection/web_connection.ts b/packages/client-web/src/connection/web_connection.ts index 9a9214d1..d91085d3 100644 --- a/packages/client-web/src/connection/web_connection.ts +++ b/packages/client-web/src/connection/web_connection.ts @@ -168,7 +168,13 @@ export class WebConnection implements Connection { try { const headers = withCompressionHeaders({ - headers: this.defaultHeaders, + headers: + params?.auth !== undefined + ? { + ...this.defaultHeaders, + Authorization: `Basic ${btoa(`${params.auth.username}:${params.auth.password}`)}`, + } + : this.defaultHeaders, compress_request: false, decompress_response: this.params.compression.decompress_response, }) diff --git a/packages/client-web/src/version.ts b/packages/client-web/src/version.ts index 287731b9..9e64831d 100644 --- a/packages/client-web/src/version.ts +++ b/packages/client-web/src/version.ts @@ -1 +1 @@ -export default '1.0.2' +export default '1.1.0'