From 1dd140911636b65b45306ccfc7eccf934a6b330e Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Mon, 17 Nov 2025 12:37:47 +0100 Subject: [PATCH 1/8] feat: Added custom SASL authenticator support. Signed-off-by: Paolo Insogna --- docs/base.md | 52 ++++++++++++++++++++++++++++++ src/clients/base/options.ts | 3 +- src/network/connection.ts | 17 ++++++++-- src/protocol/sasl/scram-sha.ts | 2 ++ test/network/connection.test.ts | 56 +++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/base.md b/docs/base.md index 4f5b212..7227854 100644 --- a/docs/base.md +++ b/docs/base.md @@ -132,4 +132,56 @@ const producer = new Producer({ }) ``` +## Connecting to Kafka via SASL using a custom authenticator + +For advanced use cases where you need full control over the SASL authentication process, you can provide a custom `authenticate` function in the `sasl` options. This allows you to implement custom authentication flows, handle complex credential management, or integrate with external authentication systems. + +Example: + +```javascript +import { Producer, stringSerializers } from '@platformatic/kafka' + +const producer = new Producer({ + clientId: 'my-producer', + bootstrapBrokers: ['localhost:9092'], + serializers: stringSerializers, + sasl: { + mechanism: 'PLAIN', + authenticate: async (mechanism, connection, authenticate, usernameProvider, passwordProvider, tokenProvider, callback) => { + try { + // Custom logic to retrieve or generate credentials + const username = typeof usernameProvider === 'function' + ? await usernameProvider() + : usernameProvider + const password = typeof passwordProvider === 'function' + ? await passwordProvider() + : passwordProvider + + // Perform the SASL authentication + const authData = Buffer.from(`\u0000${username}\u0000${password}`) + const response = await authenticate({ + authBytes: authData + }) + + callback(null, response) + } catch (err) { + callback(err) + } + } + } +}) +``` + +The `authenticate` function receives the following parameters: + +- `mechanism`: The SASL mechanism being used (e.g., 'PLAIN', 'SCRAM-SHA-256') +- `connection`: The Connection instance being authenticated +- `authenticate`: The SASL authentication API function to send auth bytes to the server +- `usernameProvider`: The username (string or async function) from the sasl options +- `passwordProvider`: The password (string or async function) from the sasl options +- `tokenProvider`: The token (string or async function) from the sasl options +- `callback`: A callback function to call with the authentication result + +**Important**: The `authenticate` function should never throw exceptions, especially when using async functions. The function is not awaited and exceptions are not handled, which can lead to memory leaks, resource leaks, and unexpected behavior. Always wrap your code in a try-catch block and pass errors to the callback instead. + [node-socket-write]: https://nodejs.org/dist/latest/docs/api/stream.html#writablewritechunk-encoding-callback diff --git a/src/clients/base/options.ts b/src/clients/base/options.ts index c6665be..f29d168 100644 --- a/src/clients/base/options.ts +++ b/src/clients/base/options.ts @@ -46,7 +46,8 @@ export const baseOptionsSchema = { username: { oneOf: [{ type: 'string' }, { function: true }] }, password: { oneOf: [{ type: 'string' }, { function: true }] }, token: { oneOf: [{ type: 'string' }, { function: true }] }, - authBytesValidator: { function: true } + authBytesValidator: { function: true }, + authenticate: { function: true } }, required: ['mechanism'], additionalProperties: false diff --git a/src/network/connection.ts b/src/network/connection.ts index 06e062f..7eaf018 100644 --- a/src/network/connection.ts +++ b/src/network/connection.ts @@ -6,7 +6,7 @@ import { type CallbackWithPromise, createPromisifiedCallback, kCallbackPromise } import { type Callback, type ResponseParser } from '../apis/definitions.ts' import { allowedSASLMechanisms, SASLMechanisms, type SASLMechanismValue } from '../apis/enumerations.ts' import { saslAuthenticateV2, saslHandshakeV1 } from '../apis/index.ts' -import { type SaslAuthenticateResponse } from '../apis/security/sasl-authenticate-v2.ts' +import { type SaslAuthenticateResponse, type SASLAuthenticationAPI } from '../apis/security/sasl-authenticate-v2.ts' import { connectionsApiChannel, connectionsConnectsChannel, @@ -44,6 +44,15 @@ export interface SASLOptions { password?: string | SASLCredentialProvider token?: string | SASLCredentialProvider authBytesValidator?: (authBytes: Buffer, callback: CallbackWithPromise) => void + authenticate?: ( + mechanism: SASLMechanismValue, + connection: Connection, + authenticate: SASLAuthenticationAPI, + usernameProvider: string | SASLCredentialProvider | undefined, + passwordProvider: string | SASLCredentialProvider | undefined, + tokenProvider: string | SASLCredentialProvider | undefined, + callback: CallbackWithPromise + ) => void } export interface ConnectionOptions { @@ -384,7 +393,7 @@ export class Connection extends EventEmitter { this.#status = ConnectionStatuses.AUTHENTICATING } - const { mechanism, username, password, token } = this.#options.sasl! + const { mechanism, username, password, token, authenticate } = this.#options.sasl! if (!allowedSASLMechanisms.includes(mechanism)) { this.#onConnectionError( @@ -411,7 +420,9 @@ export class Connection extends EventEmitter { this.emit('sasl:handshake', response.mechanisms) const callback = this.#onSaslAuthenticate.bind(this, host, port, diagnosticContext) - if (mechanism === SASLMechanisms.PLAIN) { + if (authenticate) { + authenticate(mechanism, this, saslAuthenticateV2.api, username, password, token, callback) + } else if (mechanism === SASLMechanisms.PLAIN) { saslPlain.authenticate(saslAuthenticateV2.api, this, username!, password!, callback) } else if (mechanism === SASLMechanisms.OAUTHBEARER) { saslOAuthBearer.authenticate(saslAuthenticateV2.api, this, token!, callback) diff --git a/src/protocol/sasl/scram-sha.ts b/src/protocol/sasl/scram-sha.ts index 842d515..7fd2a58 100644 --- a/src/protocol/sasl/scram-sha.ts +++ b/src/protocol/sasl/scram-sha.ts @@ -26,11 +26,13 @@ export interface ScramCryptoModule { export const ScramAlgorithms = { 'SHA-256': { + id: 'SHA-256', keyLength: 32, algorithm: 'sha256', minIterations: 4096 }, 'SHA-512': { + id: 'SHA-512', keyLength: 64, algorithm: 'sha512', minIterations: 4096 diff --git a/test/network/connection.test.ts b/test/network/connection.test.ts index 139aa30..289703d 100644 --- a/test/network/connection.test.ts +++ b/test/network/connection.test.ts @@ -3,9 +3,14 @@ import { readFile } from 'node:fs/promises' import { type AddressInfo, createServer as createNetworkServer, type Server, Socket } from 'node:net' import test, { before, type TestContext } from 'node:test' import { createServer as createSecureServer, TLSSocket } from 'node:tls' +import { + type SaslAuthenticateResponse, + type SASLAuthenticationAPI +} from '../../src/apis/security/sasl-authenticate-v2.ts' import { allowedSASLMechanisms, AuthenticationError, + type CallbackWithPromise, Connection, type ConnectionDiagnosticEvent, connectionsApiChannel, @@ -17,12 +22,18 @@ import { parseBroker, PromiseWithResolvers, type Reader, + type SASLCredentialProvider, saslHandshakeV1, SASLMechanisms, + type SASLMechanismValue, + saslOAuthBearer, type SASLOptions, + saslPlain, + saslScramSha, UnexpectedCorrelationIdError, Writer } from '../../src/index.ts' +import { defaultCrypto, type ScramAlgorithm } from '../../src/protocol/sasl/scram-sha.ts' import { createScramUsers } from '../fixtures/create-users.ts' import { createCreationChannelVerifier, @@ -965,6 +976,51 @@ for (const mechanism of allowedSASLMechanisms) { }) } +for (const mechanism of allowedSASLMechanisms) { + const sasl: SASLOptions = + mechanism === 'OAUTHBEARER' ? { mechanism, token: 'token' } : { mechanism, username: 'admin', password: 'admin' } + + sasl.authenticate = async function customSaslAuthenticate ( + mechanism: SASLMechanismValue, + connection: Connection, + authenticate: SASLAuthenticationAPI, + usernameProvider: string | SASLCredentialProvider | undefined, + passwordProvider: string | SASLCredentialProvider | undefined, + tokenProvider: string | SASLCredentialProvider | undefined, + callback: CallbackWithPromise + ) { + switch (mechanism) { + case SASLMechanisms.PLAIN: + saslPlain.authenticate(authenticate, connection, usernameProvider!, passwordProvider!, callback) + + break + case SASLMechanisms.OAUTHBEARER: + saslOAuthBearer.authenticate(authenticate, connection, tokenProvider!, callback) + break + case SASLMechanisms.SCRAM_SHA_256: + case SASLMechanisms.SCRAM_SHA_512: + saslScramSha.authenticate( + authenticate, + connection, + mechanism.substring(6) as ScramAlgorithm, + usernameProvider!, + passwordProvider!, + defaultCrypto, + callback + ) + break + } + } + + test.only(`Connection.connect should connect to SASL protected broker using SASL/${mechanism} using a custom implementation`, async t => { + const connection = new Connection('clientId', { sasl }) + t.after(() => connection.close()) + + await connection.connect(saslBroker.host, saslBroker.port) + await metadataV12.api.async(connection, []) + }) +} + test('Connection.connect should reject unsupported mechanisms', async () => { const connection = new Connection('clientId', { // @ts-expect-error - Purposefully using an invalid mechanism From 1e0fee1b072e17d8d15f744707a79c79d45019bc Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Mon, 3 Nov 2025 12:07:41 +0100 Subject: [PATCH 2/8] wip: Added Kerberos to Docker Compose. Signed-off-by: Paolo Insogna --- docker-compose.yml | 51 ++++++++++++++++++++++++++++++++ docker/kerberos/README.md | 10 +++++++ docker/kerberos/init.sh | 26 ++++++++++++++++ docker/kerberos/kdc.conf | 11 +++++++ docker/kerberos/krb5-broker.conf | 14 +++++++++ docker/kerberos/krb5-kdc.conf | 14 +++++++++ docker/sasl/jaas-kerberos.conf | 9 ++++++ 7 files changed, 135 insertions(+) create mode 100644 docker/kerberos/README.md create mode 100644 docker/kerberos/init.sh create mode 100644 docker/kerberos/kdc.conf create mode 100644 docker/kerberos/krb5-broker.conf create mode 100644 docker/kerberos/krb5-kdc.conf create mode 100644 docker/sasl/jaas-kerberos.conf diff --git a/docker-compose.yml b/docker-compose.yml index 9b38120..90fc002 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,23 @@ services: + kdc: + image: alpine:latest + container_name: kdc + ports: + - '8000:88/tcp' + - '8000:88/udp' + - '8001:749' + volumes: + - './docker/kerberos/krb5-kdc.conf:/etc/krb5.conf:ro' + - './docker/kerberos/kdc.conf:/var/lib/krb5kdc/kdc.conf:ro' + - './docker/kerberos/init.sh:/init.sh:ro' + - './tmp/kerberos:/data' + entrypoint: ['/bin/sh', '/init.sh'] + healthcheck: + test: ['CMD', 'kadmin.local', '-q', 'list_principals'] + interval: 10s + timeout: 5s + retries: 5 + broker-single: # Rule of thumb: Confluent Kafka Version = Apache Kafka Version + 4.0.0 image: &image confluentinc/cp-kafka:${KAFKA_VERSION:-8.0.0} @@ -74,6 +93,38 @@ services: KAFKA_SASL_OAUTHBEARER_EXPECTED_AUDIENCE: users KAFKA_SASL_OAUTHBEARER_EXPECTED_SCOPE: test + broker-sasl-kerberos: + image: *image + container_name: broker-sasl-kerberos + ports: + - "9003:9092" # SASL + - "19003:19092" # PLAIN TEXT - Used to create users + healthcheck: *health_check + volumes: + - "./docker/sasl/jaas-kerberos.conf:/etc/kafka/jaas.conf:ro" + - "./docker/kerberos/krb5-broker.conf:/etc/krb5.conf:ro" + - "./tmp/kerberos/broker.keytab:/etc/kafka/broker.keytab:ro" + depends_on: + kdc: + condition: service_healthy + environment: + <<: *common_config + # Broker specific general and port options + KAFKA_LISTENERS: "SASL://:9092,DOCKER://:19092,CONTROLLER://:29092" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "SASL:SASL_PLAINTEXT,DOCKER:PLAINTEXT,CONTROLLER:PLAINTEXT" + KAFKA_ADVERTISED_LISTENERS: "SASL://localhost:9003,DOCKER://broker-sasl-kerberos:19092" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@broker-sasl-kerberos:29092" + # SASL + KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/jaas.conf -Djava.security.krb5.conf=/etc/krb5.conf" + KAFKA_LISTENER_NAME_SASL_GSSAPI_SASL_JAAS_CONFIG: 'com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true storeKey=true keyTab="/etc/kafka/broker.keytab" principal="broker/broker-sasl-kerberos@EXAMPLE.COM";' + KAFKA_CONNECTIONS_MAX_REAUTH_MS: 5000 + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "false" + KAFKA_SUPER_USERS: 'User:admin;User:broker/broker-sasl-kerberos@EXAMPLE.COM;User:admin-keytab/localhost@EXAMPLE.COM;User:admin-password/localhost@EXAMPLE.COM' + KAFKA_SASL_ENABLED_MECHANISMS: "GSSAPI" + KAFKA_SASL_MECHANISM_CONTROLLER_PROTOCOL: "PLAIN" + KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: "PLAIN" + KAFKA_SASL_KERBEROS_SERVICE_NAME: 'kafka' + broker-cluster-1: image: *image container_name: broker-cluster-1 diff --git a/docker/kerberos/README.md b/docker/kerberos/README.md new file mode 100644 index 0000000..de84489 --- /dev/null +++ b/docker/kerberos/README.md @@ -0,0 +1,10 @@ +To create `kafka.keytab`: + +``` +ktutil +addent -password -p admin/localhost@example.com -k 1 -e aes256-cts-hmac-sha1-96 +write_kt kafka.keytab +quit +``` + +On Mac, use `ktutil` from `krb5`, installed via Homebrew diff --git a/docker/kerberos/init.sh b/docker/kerberos/init.sh new file mode 100644 index 0000000..0241222 --- /dev/null +++ b/docker/kerberos/init.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -e + +# Setup KDC if needed +if [ ! -f /var/lib/krb5kdc/principal ]; then + echo "Setting up KDC ..." + + apk add --no-cache krb5-server krb5 + kdb5_util create -s -P password + + # # ACL file + echo "*/admin@EXAMPLE.COM *" > /var/lib/krb5kdc/kadm5.acl + + # Create principals + kadmin.local -q "addprinc -pw admin admin@EXAMPLE.COM" # Main administrator + kadmin.local -q "addprinc -randkey broker/broker-sasl-kerberos@EXAMPLE.COM" # Kafka broker + kadmin.local -q "addprinc -randkey admin-keytab@EXAMPLE.COM" # Client with keytab + kadmin.local -q "addprinc -pw admin admin-password@EXAMPLE.COM" # Client with password + + # Genera keytab + kadmin.local -q "ktadd -k /data/broker.keytab broker/broker-sasl-kerberos@EXAMPLE.COM" + kadmin.local -q "ktadd -k /data/admin.keytab admin-keytab@EXAMPLE.COM" +fi + +krb5kdc +kadmind -nofork \ No newline at end of file diff --git a/docker/kerberos/kdc.conf b/docker/kerberos/kdc.conf new file mode 100644 index 0000000..b244fbe --- /dev/null +++ b/docker/kerberos/kdc.conf @@ -0,0 +1,11 @@ +[kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + +[realms] + EXAMPLE.COM = { + acl_file = /var/lib/krb5kdc/kadm5.acl + dict_file = /usr/share/dict/words + admin_keytab = /var/lib/krb5kdc/kadm5.keytab + supported_enctypes = aes256-cts:normal aes128-cts:normal + } \ No newline at end of file diff --git a/docker/kerberos/krb5-broker.conf b/docker/kerberos/krb5-broker.conf new file mode 100644 index 0000000..834571b --- /dev/null +++ b/docker/kerberos/krb5-broker.conf @@ -0,0 +1,14 @@ +[libdefaults] + default_realm = EXAMPLE.COM + dns_lookup_realm = false + dns_lookup_kdc = false + +[realms] + EXAMPLE.COM = { + kdc = kdc:88 + admin_server = kdc:749 + } + +[domain_realm] + .example.com = EXAMPLE.COM + example.com = EXAMPLE.COM \ No newline at end of file diff --git a/docker/kerberos/krb5-kdc.conf b/docker/kerberos/krb5-kdc.conf new file mode 100644 index 0000000..c84a63a --- /dev/null +++ b/docker/kerberos/krb5-kdc.conf @@ -0,0 +1,14 @@ +[libdefaults] + default_realm = EXAMPLE.COM + dns_lookup_realm = false + dns_lookup_kdc = false + +[realms] + EXAMPLE.COM = { + kdc = localhost:88 + admin_server = localhost:749 + } + +[domain_realm] + .example.com = EXAMPLE.COM + example.com = EXAMPLE.COM \ No newline at end of file diff --git a/docker/sasl/jaas-kerberos.conf b/docker/sasl/jaas-kerberos.conf new file mode 100644 index 0000000..c1a14e1 --- /dev/null +++ b/docker/sasl/jaas-kerberos.conf @@ -0,0 +1,9 @@ +KafkaServer { + com.sun.security.auth.module.Krb5LoginModule required + useKeyTab=true + storeKey=true + keyTab="/etc/kafka/broker.keytab" + principal="broker/broker-sasl-kerberos@EXAMPLE.COM" + serviceName="kafka" + useTicketCache=false; +}; \ No newline at end of file From 96796a6ae1ec7c0f2a95c9836b9b622693032d56 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Mon, 17 Nov 2025 17:34:21 +0100 Subject: [PATCH 3/8] chore: POC authenticator with node-kerberos. Signed-off-by: Paolo Insogna --- package.json | 6 +- pnpm-lock.yaml | 211 ++++++++++++++++ pnpm-workspace.yaml | 2 + src/apis/enumerations.ts | 3 +- src/network/connection.ts | 25 +- src/protocol/index.ts | 1 + src/protocol/sasl/oauth-bearer.ts | 5 +- src/protocol/sasl/plain.ts | 8 +- src/protocol/sasl/scram-sha.ts | 8 +- .../sasl/{credential-provider.ts => utils.ts} | 0 test/clients/base/sasl-gssapi.test.ts | 53 ++++ test/clients/base/sasl.test.ts | 5 +- test/fixtures/kerberos-authenticator.ts | 226 ++++++++++++++++++ test/helpers.ts | 1 + test/network/connection.test.ts | 49 +++- .../protocol/sasl/credential-provider.test.ts | 2 +- 16 files changed, 577 insertions(+), 28 deletions(-) create mode 100644 pnpm-workspace.yaml rename src/protocol/sasl/{credential-provider.ts => utils.ts} (100%) create mode 100644 test/clients/base/sasl-gssapi.test.ts create mode 100644 test/fixtures/kerberos-authenticator.ts diff --git a/package.json b/package.json index 3e7da5d..5e90e86 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@confluentinc/kafka-javascript": "^1.5.0", "@platformatic/rdkafka": "^4.0.0", "@types/debug": "^4.1.12", + "@types/kerberos": "^1.1.5", "@types/node": "^22.18.5", "@types/semver": "^7.7.1", "@watchable/unpromise": "^1.0.2", @@ -68,8 +69,9 @@ "eslint": "^9.35.0", "fast-jwt": "^6.0.2", "hwp": "^0.4.1", - "kafkajs": "^2.2.4", "json5": "^2.2.3", + "kafkajs": "^2.2.4", + "kerberos": "^2.2.2", "neostandard": "^0.12.2", "parse5": "^7.3.0", "prettier": "^3.6.2", @@ -82,4 +84,4 @@ "engines": { "node": ">= 20.19.4 || >= 22.18.0 || >= 24.6.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51627f1..ccaebb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@types/debug': specifier: ^4.1.12 version: 4.1.12 + '@types/kerberos': + specifier: ^1.1.5 + version: 1.1.5 '@types/node': specifier: ^22.18.5 version: 22.18.8 @@ -69,6 +72,9 @@ importers: kafkajs: specifier: ^2.2.4 version: 2.2.4 + kerberos: + specifier: ^2.2.2 + version: 2.2.2 neostandard: specifier: ^0.12.2 version: 0.12.2(@typescript-eslint/utils@8.46.0(eslint@9.37.0)(typescript@5.9.3))(eslint@9.37.0)(typescript@5.9.3) @@ -550,6 +556,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/kerberos@1.1.5': + resolution: {integrity: sha512-eljovuC0f1+6a4R8CSGwlP8P7OGygDoYJ4Yo0PtKYN4NOQEOkLH7tCQ3humCMz3lsGd0hOTyyjxHP+S3N/KtFg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -829,6 +838,9 @@ packages: bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -842,6 +854,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + c8@10.1.3: resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} engines: {node: '>=18'} @@ -872,6 +887,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -937,6 +955,14 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -975,6 +1001,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -1130,6 +1159,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1192,6 +1225,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -1237,6 +1273,9 @@ packages: get-tsconfig@4.11.0: resolution: {integrity: sha512-sNsqf7XKQ38IawiVGPOoAlqZo1DMrO7TU+ZcZwi7yLl7/7S0JwmoBMKz/IkUPhSoXM0Ng3vT0yB1iCe5XavDeQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1322,6 +1361,9 @@ packages: hwp@0.4.1: resolution: {integrity: sha512-aN+FxnVyrNmIx6ULuAyefgQffE0NF9GRzW+lB5cAp5kxN5PBdjf8Ws+IvDHHJM1a7pyXTP9t0gwz7E/Z5ikj8w==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1345,6 +1387,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1513,6 +1558,10 @@ packages: resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} engines: {node: '>=14.0.0'} + kerberos@2.2.2: + resolution: {integrity: sha512-42O7+/1Zatsc3MkxaMPpXcIl/ukIrbQaGoArZEAr6GcEi2qhfprOBYOPhj+YvSMJkEkdpTjApUx+2DuWaKwRhg==} + engines: {node: '>=12.9.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1561,6 +1610,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -1575,6 +1628,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -1591,6 +1647,9 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -1605,6 +1664,9 @@ packages: nan@2.23.0: resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1620,6 +1682,13 @@ packages: peerDependencies: eslint: ^9.0.0 + node-abi@3.80.0: + resolution: {integrity: sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1736,6 +1805,11 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1757,6 +1831,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1764,6 +1841,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1886,6 +1967,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slice-ansi@4.0.0: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} @@ -1943,6 +2030,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1963,6 +2054,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -1999,6 +2097,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2478,6 +2579,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/kerberos@1.1.5': {} + '@types/ms@2.1.0': {} '@types/node@22.18.8': @@ -2773,6 +2876,12 @@ snapshots: bintrees@1.0.2: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bn.js@4.12.2: {} brace-expansion@1.1.12: @@ -2788,6 +2897,11 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + c8@10.1.3: dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2826,6 +2940,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chownr@1.1.4: {} + chownr@2.0.0: {} cleaner-spec-reporter@0.5.0: {} @@ -2886,6 +3002,12 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2924,6 +3046,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -3196,6 +3322,8 @@ snapshots: esutils@2.0.3: {} + expand-template@2.0.3: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -3258,6 +3386,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -3321,6 +3451,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3405,6 +3537,8 @@ snapshots: hwp@0.4.1: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3423,6 +3557,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3600,6 +3736,11 @@ snapshots: kafkajs@2.2.4: {} + kerberos@2.2.2: + dependencies: + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3659,6 +3800,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.0.3: @@ -3673,6 +3816,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@3.3.6: dependencies: yallist: 4.0.0 @@ -3686,6 +3831,8 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mnemonist@0.40.3: @@ -3696,6 +3843,8 @@ snapshots: nan@2.23.0: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -3721,6 +3870,12 @@ snapshots: - supports-color - typescript + node-abi@3.80.0: + dependencies: + semver: 7.7.2 + + node-addon-api@6.1.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -3834,6 +3989,21 @@ snapshots: possible-typed-array-names@1.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.80.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-plugin-space-before-function-paren@0.0.8(prettier@3.6.2): @@ -3853,10 +4023,22 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-is@16.13.1: {} readable-stream@3.6.2: @@ -4000,6 +4182,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + slice-ansi@4.0.0: dependencies: ansi-styles: 4.3.0 @@ -4105,6 +4295,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -4123,6 +4315,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@6.2.1: dependencies: chownr: 2.0.0 @@ -4165,6 +4372,10 @@ snapshots: tslib@2.8.1: optional: true + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..419c01b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - kerberos diff --git a/src/apis/enumerations.ts b/src/apis/enumerations.ts index fb31983..09fddb4 100644 --- a/src/apis/enumerations.ts +++ b/src/apis/enumerations.ts @@ -3,7 +3,8 @@ export const SASLMechanisms = { PLAIN: 'PLAIN', SCRAM_SHA_256: 'SCRAM-SHA-256', SCRAM_SHA_512: 'SCRAM-SHA-512', - OAUTHBEARER: 'OAUTHBEARER' + OAUTHBEARER: 'OAUTHBEARER', + GSSAPI: 'GSSAPI' } as const export const allowedSASLMechanisms = Object.values(SASLMechanisms) as SASLMechanismValue[] diff --git a/src/network/connection.ts b/src/network/connection.ts index 7eaf018..f043720 100644 --- a/src/network/connection.ts +++ b/src/network/connection.ts @@ -38,21 +38,23 @@ export interface Broker { port: number } +export type SASLCustomAuthenticator = ( + mechanism: SASLMechanismValue, + connection: Connection, + authenticate: SASLAuthenticationAPI, + usernameProvider: string | SASLCredentialProvider | undefined, + passwordProvider: string | SASLCredentialProvider | undefined, + tokenProvider: string | SASLCredentialProvider | undefined, + callback: CallbackWithPromise +) => void + export interface SASLOptions { mechanism: SASLMechanismValue username?: string | SASLCredentialProvider password?: string | SASLCredentialProvider token?: string | SASLCredentialProvider authBytesValidator?: (authBytes: Buffer, callback: CallbackWithPromise) => void - authenticate?: ( - mechanism: SASLMechanismValue, - connection: Connection, - authenticate: SASLAuthenticationAPI, - usernameProvider: string | SASLCredentialProvider | undefined, - passwordProvider: string | SASLCredentialProvider | undefined, - tokenProvider: string | SASLCredentialProvider | undefined, - callback: CallbackWithPromise - ) => void + authenticate?: SASLCustomAuthenticator } export interface ConnectionOptions { @@ -426,6 +428,11 @@ export class Connection extends EventEmitter { saslPlain.authenticate(saslAuthenticateV2.api, this, username!, password!, callback) } else if (mechanism === SASLMechanisms.OAUTHBEARER) { saslOAuthBearer.authenticate(saslAuthenticateV2.api, this, token!, callback) + } else if (mechanism === SASLMechanisms.GSSAPI) { + callback( + new UserError('No custom SASL/GSSAPI authenticator provided.'), + undefined as unknown as SaslAuthenticateResponse + ) } else { saslScramSha.authenticate( saslAuthenticateV2.api, diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 1754d1e..ac4b545 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -11,5 +11,6 @@ export * from './records.ts' export * as saslOAuthBearer from './sasl/oauth-bearer.ts' export * as saslPlain from './sasl/plain.ts' export * as saslScramSha from './sasl/scram-sha.ts' +export * as saslUtils from './sasl/utils.ts' export * from './varint.ts' export * from './writer.ts' diff --git a/src/protocol/sasl/oauth-bearer.ts b/src/protocol/sasl/oauth-bearer.ts index 02e252f..20dc294 100644 --- a/src/protocol/sasl/oauth-bearer.ts +++ b/src/protocol/sasl/oauth-bearer.ts @@ -2,7 +2,7 @@ import { createPromisifiedCallback, kCallbackPromise, type CallbackWithPromise } import { type SASLAuthenticationAPI, type SaslAuthenticateResponse } from '../../apis/security/sasl-authenticate-v2.ts' import { AuthenticationError } from '../../errors.ts' import { type Connection, type SASLCredentialProvider } from '../../network/connection.ts' -import { getCredential } from './credential-provider.ts' +import { getCredential } from './utils.ts' export function jwtValidateAuthenticationBytes (authBytes: Buffer, callback: CallbackWithPromise): void { let authData: Record = {} @@ -52,7 +52,8 @@ export function authenticate ( getCredential('SASL/OAUTHBEARER token', tokenOrProvider, (error, token) => { if (error) { - return callback!(error, undefined as unknown as SaslAuthenticateResponse) + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return } authenticateAPI(connection, Buffer.from(`n,,\x01auth=Bearer ${token}\x01\x01`), callback!) diff --git a/src/protocol/sasl/plain.ts b/src/protocol/sasl/plain.ts index 0ada47e..648f028 100644 --- a/src/protocol/sasl/plain.ts +++ b/src/protocol/sasl/plain.ts @@ -1,7 +1,7 @@ import { createPromisifiedCallback, kCallbackPromise, type CallbackWithPromise } from '../../apis/callbacks.ts' import { type SASLAuthenticationAPI, type SaslAuthenticateResponse } from '../../apis/security/sasl-authenticate-v2.ts' import { type Connection, type SASLCredentialProvider } from '../../network/connection.ts' -import { getCredential } from './credential-provider.ts' +import { getCredential } from './utils.ts' export function authenticate ( authenticateAPI: SASLAuthenticationAPI, @@ -29,12 +29,14 @@ export function authenticate ( getCredential('SASL/PLAIN username', usernameProvider, (error, username) => { if (error) { - return callback!(error, undefined as unknown as SaslAuthenticateResponse) + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return } getCredential('SASL/PLAIN password', passwordProvider, (error, password) => { if (error) { - return callback!(error, undefined as unknown as SaslAuthenticateResponse) + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return } authenticateAPI(connection, Buffer.from(['', username, password].join('\0')), callback) diff --git a/src/protocol/sasl/scram-sha.ts b/src/protocol/sasl/scram-sha.ts index 7fd2a58..002883f 100644 --- a/src/protocol/sasl/scram-sha.ts +++ b/src/protocol/sasl/scram-sha.ts @@ -3,7 +3,7 @@ import { createPromisifiedCallback, kCallbackPromise, type CallbackWithPromise } import { type SASLAuthenticationAPI, type SaslAuthenticateResponse } from '../../apis/security/sasl-authenticate-v2.ts' import { AuthenticationError } from '../../errors.ts' import { type Connection, type SASLCredentialProvider } from '../../network/connection.ts' -import { getCredential } from './credential-provider.ts' +import { getCredential } from './utils.ts' const GS2_HEADER = 'n,,' const GS2_HEADER_BASE64 = Buffer.from(GS2_HEADER).toString('base64') @@ -227,12 +227,14 @@ export function authenticate ( getCredential(`SASL/SCRAM-${algorithm} username`, usernameProvider, (error, username) => { if (error) { - return callback!(error, undefined as unknown as SaslAuthenticateResponse) + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return } getCredential(`SASL/SCRAM-${algorithm} password`, passwordProvider, (error, password) => { if (error) { - return callback!(error, undefined as unknown as SaslAuthenticateResponse) + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return } performAuthentication(connection, algorithm, definition, authenticateAPI, crypto, username, password, callback) diff --git a/src/protocol/sasl/credential-provider.ts b/src/protocol/sasl/utils.ts similarity index 100% rename from src/protocol/sasl/credential-provider.ts rename to src/protocol/sasl/utils.ts diff --git a/test/clients/base/sasl-gssapi.test.ts b/test/clients/base/sasl-gssapi.test.ts new file mode 100644 index 0000000..90a6f7c --- /dev/null +++ b/test/clients/base/sasl-gssapi.test.ts @@ -0,0 +1,53 @@ +import { deepStrictEqual, ok, rejects } from 'node:assert' +import { test } from 'node:test' +import { Base, MultipleErrors, NetworkError, UserError } from '../../../src/index.ts' +import { createAuthenticator } from '../../fixtures/kerberos-authenticator.ts' + +test('should not connect to SASL protected broker by default', async t => { + const base = new Base({ clientId: 'clientId', bootstrapBrokers: ['localhost:9003'], strict: true, retries: false }) + t.after(() => base.close()) + + await rejects(() => base.metadata({ topics: [] })) +}) + +test('should fail connecttion to SASL protected broker using SASL/GSSAPI when no custom authenticator is provided', async t => { + const base = new Base({ + clientId: 'clientId', + bootstrapBrokers: ['localhost:9003'], + strict: true, + retries: 0, + sasl: { mechanism: 'GSSAPI', username: 'admin-password@EXAMPLE.COM', password: 'admin' } + }) + + t.after(() => base.close()) + + try { + await base.metadata({ topics: [] }) + throw new Error('Expected error not thrown') + } catch (error) { + ok(error instanceof MultipleErrors) + ok(error.errors[0] instanceof NetworkError) + ok(error.errors[0].cause instanceof UserError) + deepStrictEqual(error.errors[0].cause.message, 'No custom SASL/GSSAPI authenticator provided.') + } +}) + +test('should connect to SASL protected broker using SASL/GSSAPI using a custom authenticator', async t => { + const base = new Base({ + clientId: 'clientId', + bootstrapBrokers: ['localhost:9003'], + strict: true, + retries: 0, + sasl: { + mechanism: 'GSSAPI', + username: 'admin-password@EXAMPLE.COM', + password: 'admin', + authenticate: await createAuthenticator('admin-password@EXAMPLE.COM', 'admin', 'EXAMPLE.COM', 'localhost:8000') + } + }) + + t.after(() => base.close()) + + const metadata = await base.metadata({ topics: [] }) + deepStrictEqual(metadata.brokers.get(1), { host: 'localhost', port: 9003 }) +}) diff --git a/test/clients/base/sasl.test.ts b/test/clients/base/sasl.test.ts index 266b384..1d68544 100644 --- a/test/clients/base/sasl.test.ts +++ b/test/clients/base/sasl.test.ts @@ -30,9 +30,8 @@ test('UNAUTHENTICATED - should not connect to SASL protected broker by default', }) for (const mechanism of allowedSASLMechanisms) { - if (mechanism === 'OAUTHBEARER') { - // GSSAPI requires a properly configured Kerberos environment - // which is out of scope for these tests + // These are tested in their own file + if (mechanism === 'OAUTHBEARER' || mechanism === 'GSSAPI') { continue } diff --git a/test/fixtures/kerberos-authenticator.ts b/test/fixtures/kerberos-authenticator.ts new file mode 100644 index 0000000..63e8a20 --- /dev/null +++ b/test/fixtures/kerberos-authenticator.ts @@ -0,0 +1,226 @@ +import krb, { type KerberosClient } from 'kerberos' +import { execSync } from 'node:child_process' +import { rm } from 'node:fs' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { + AuthenticationError, + EMPTY_BUFFER, + saslUtils, + type Callback, + type CallbackWithPromise, + type Connection, + type saslAuthenticateV2, + type SASLCredentialProvider, + type SASLCustomAuthenticator, + type SASLMechanismValue +} from '../../src/index.ts' + +type SaslAuthenticateResponse = saslAuthenticateV2.SaslAuthenticateResponse +type SASLAuthenticationAPI = saslAuthenticateV2.SASLAuthenticationAPI + +function createKerberosAuthenticationError (message: string, kerberosError: string): AuthenticationError { + return new AuthenticationError(message, { kerberosError }) +} + +function performChallenge ( + connection: Connection, + authenticate: SASLAuthenticationAPI, + client: KerberosClient, + step: string, + callback: CallbackWithPromise +): void { + client.step(step, async (error: string | null, challenge: string) => { + if (error) { + callback( + createKerberosAuthenticationError('Cannot continue Kerberos step challenge.', error), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + const challengeBuffer = challenge ? Buffer.from(challenge, 'base64') : EMPTY_BUFFER + + authenticate(connection, challengeBuffer, (error, response) => { + if (error) { + callback( + new AuthenticationError('SASL authentication failed.', { cause: error }), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + if (response.authBytes.length === 0) { + callback(null, response) + return + } + + if (client.contextComplete) { + client.unwrap(response.authBytes.toString('base64'), (error: string | null) => { + if (error) { + callback( + createKerberosAuthenticationError('Cannot unwrap Kerberose response', error), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + // Byte 0: No security layer; Byte 1-3: max message size - 0=none + client.wrap(Buffer.from([1, 0, 0, 0]).toString('base64'), {}, (error: string | null, wrapped: string) => { + if (error) { + callback( + createKerberosAuthenticationError('Cannot wrap Kerberos response.', error), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + // Invia la risposta wrappata a Kafka + authenticate(connection, Buffer.from(wrapped, 'base64'), (error, response) => { + if (error) { + callback( + new AuthenticationError('SASL authentication failed.', { cause: error }), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + callback(null, response) + }) + }) + }) + + return + } + + // Altrimenti continua normalmente + performChallenge(connection, authenticate, client, response.authBytes.toString('base64'), callback) + }) + }) +} + +function restoreEnvironment ( + callback: Callback, + kerberosRoot: string, + originalKrb5Config: string | undefined, + originalKrbCCName: string | undefined, + error: Error | null, + response: SaslAuthenticateResponse +): void { + if (typeof originalKrb5Config !== 'undefined') { + process.env.KRB5_CONFIG = originalKrb5Config + } else { + delete process.env.KRB5_CONFIG + } + + if (typeof originalKrbCCName !== 'undefined') { + process.env.KRB5CCNAME = originalKrbCCName + } else { + delete process.env.KRB5CCNAME + } + + rm(kerberosRoot, { recursive: true, force: true }, () => { + callback(error, response) + }) +} + +function authenticate ( + service: string, + kerberosRoot: string, + _m: SASLMechanismValue, + connection: Connection, + authenticate: saslAuthenticateV2.SASLAuthenticationAPI, + usernameProvider: string | SASLCredentialProvider | undefined, + passwordProvider: string | SASLCredentialProvider | undefined, + _t: string | SASLCredentialProvider | undefined, + callback: CallbackWithPromise +): void { + saslUtils.getCredential('SASL/GSSAPI username', usernameProvider!, (error, username) => { + if (error) { + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return + } + + saslUtils.getCredential('SASL/GSSAPI password', passwordProvider!, (error, password) => { + if (error) { + callback!(error, undefined as unknown as SaslAuthenticateResponse) + return + } + + const afterRestoreCallback = restoreEnvironment.bind( + null, + callback!, + kerberosRoot, + process.env.KRB5_CONFIG, + process.env.KRB5CCNAME + ) + process.env.KRB5_CONFIG = `${kerberosRoot}/krb5.conf` + process.env.KRB5CCNAME = `${kerberosRoot}/krb5.cache` + + let args = '' + if (password.startsWith('keytab:')) { + args = `-kt ${password.substring('keytab:'.length)}` + } else { + args = `--password-file=${kerberosRoot}/password` + } + + // Import the password via kinit + try { + execSync(`kinit ${args} ${username}`, { stdio: 'pipe' }) + } catch (error) { + afterRestoreCallback( + new AuthenticationError('Cannot execute kinit to import user Kerberos credentials', { error }), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + krb.initializeClient(service, { principal: username }, (error: string | null, client: KerberosClient) => { + if (error) { + callback( + createKerberosAuthenticationError('Cannot initialize Kerberos client.', error), + undefined as unknown as SaslAuthenticateResponse + ) + return + } + + performChallenge(connection, authenticate, client, '', afterRestoreCallback) + }) + }) + }) +} + +export async function createAuthenticator ( + _u: string, + password: string, + realm: string, + kdc: string +): Promise { + const tmpDir = await mkdtemp(resolve(tmpdir(), 'sasl-gssapi-')) + + await writeFile( + `${tmpDir}/krb5.conf`, + ` +[libdefaults] + default_realm = ${realm} + default_ccache_name = FILE:${tmpDir}/krb5.cache + +[realms] + ${realm} = { + kdc = ${kdc} + } + +[domain_realm] + .${realm.toLowerCase()} = ${realm} + ${realm.toLowerCase()} = ${realm} +`, + 'utf-8' + ) + + if (!password.startsWith('keytab:')) { + await writeFile(`${tmpDir}/password`, password, 'utf-8') + } + + return authenticate.bind(null, 'broker@broker-sasl-kerberos', tmpDir) +} diff --git a/test/helpers.ts b/test/helpers.ts index eb97d98..2bd5efc 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -36,6 +36,7 @@ import { export const kafkaBootstrapServers = ['localhost:9011'] export const kafkaSaslBootstrapServers = ['localhost:9002'] +export const kafkaSaslKerberosBootstrapServers = ['localhost:9003'] export const mockedErrorMessage = 'Cannot connect to any broker.' export const mockedOperationId = -1n let kafkaVersion = process.env.KAFKA_VERSION diff --git a/test/network/connection.test.ts b/test/network/connection.test.ts index 289703d..fd98ddc 100644 --- a/test/network/connection.test.ts +++ b/test/network/connection.test.ts @@ -35,10 +35,12 @@ import { } from '../../src/index.ts' import { defaultCrypto, type ScramAlgorithm } from '../../src/protocol/sasl/scram-sha.ts' import { createScramUsers } from '../fixtures/create-users.ts' +import { createAuthenticator } from '../fixtures/kerberos-authenticator.ts' import { createCreationChannelVerifier, createTracingChannelVerifier, kafkaSaslBootstrapServers, + kafkaSaslKerberosBootstrapServers, mockConnectionAPI, mockedErrorMessage, mockedOperationId @@ -964,8 +966,25 @@ test('Connection.connect should not connect to SASL protected broker by default' }) for (const mechanism of allowedSASLMechanisms) { - const sasl: SASLOptions = - mechanism === 'OAUTHBEARER' ? { mechanism, token: 'token' } : { mechanism, username: 'admin', password: 'admin' } + let sasl: SASLOptions + let saslBroker = parseBroker(kafkaSaslBootstrapServers[0]) + + switch (mechanism) { + case SASLMechanisms.OAUTHBEARER: + sasl = { mechanism, token: 'token' } + break + case SASLMechanisms.GSSAPI: + saslBroker = parseBroker(kafkaSaslKerberosBootstrapServers[0]) + sasl = { + mechanism, + username: 'admin-password@EXAMPLE.COM', + password: 'admin', + authenticate: await createAuthenticator('admin-password@EXAMPLE.COM', 'admin', 'EXAMPLE.COM', 'localhost:8000') + } + break + default: + sasl = { mechanism, username: 'admin', password: 'admin' } + } test(`Connection.connect should connect to SASL protected broker using SASL/${mechanism}`, async t => { const connection = new Connection('clientId', { sasl }) @@ -980,6 +999,18 @@ for (const mechanism of allowedSASLMechanisms) { const sasl: SASLOptions = mechanism === 'OAUTHBEARER' ? { mechanism, token: 'token' } : { mechanism, username: 'admin', password: 'admin' } + const broker = + mechanism === SASLMechanisms.GSSAPI + ? parseBroker(kafkaSaslKerberosBootstrapServers[0]) + : parseBroker(kafkaSaslBootstrapServers[0]) + + const gssapiAuthenticate = await createAuthenticator( + 'admin-password@EXAMPLE.COM', + 'admin', + 'EXAMPLE.COM', + 'localhost:8000' + ) + sasl.authenticate = async function customSaslAuthenticate ( mechanism: SASLMechanismValue, connection: Connection, @@ -1009,14 +1040,24 @@ for (const mechanism of allowedSASLMechanisms) { callback ) break + case SASLMechanisms.GSSAPI: + gssapiAuthenticate( + mechanism, + connection, + authenticate, + usernameProvider, + passwordProvider, + tokenProvider, + callback + ) } } - test.only(`Connection.connect should connect to SASL protected broker using SASL/${mechanism} using a custom implementation`, async t => { + test(`Connection.connect should connect to SASL protected broker using SASL/${mechanism} using a custom implementation`, async t => { const connection = new Connection('clientId', { sasl }) t.after(() => connection.close()) - await connection.connect(saslBroker.host, saslBroker.port) + await connection.connect(broker.host, broker.port) await metadataV12.api.async(connection, []) }) } diff --git a/test/protocol/sasl/credential-provider.test.ts b/test/protocol/sasl/credential-provider.test.ts index 243f787..c4b347c 100644 --- a/test/protocol/sasl/credential-provider.test.ts +++ b/test/protocol/sasl/credential-provider.test.ts @@ -1,7 +1,7 @@ import { deepStrictEqual } from 'node:assert' import { test } from 'node:test' import { AuthenticationError } from '../../../src/errors.ts' -import { getCredential } from '../../../src/protocol/sasl/credential-provider.ts' +import { getCredential } from '../../../src/protocol/sasl/utils.ts' test('getCredential with string credential', (_, done) => { const credential = 'test-credential' From d3312d7659b947080f23e2d5771b09c1e8e88609 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Tue, 2 Dec 2025 15:42:59 +0100 Subject: [PATCH 4/8] fixup Signed-off-by: Paolo Insogna --- .github/workflows/ci.yml | 2 +- docker-compose.yml | 5 ++++- docker/kerberos/Dockerfile | 2 ++ docker/kerberos/init.sh | 13 ++++++++----- test/fixtures/kerberos-authenticator.ts | 1 - 5 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 docker/kerberos/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1bb7f4..b55e7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - name: Start Kafka (${{ matrix.confluent-kafka-version }}) Cluster - run: docker compose up -d --wait + run: docker compose up --build --force-recreate -d --wait env: KAFKA_VERSION: ${{ matrix.confluent-kafka-version }} - name: Run Tests diff --git a/docker-compose.yml b/docker-compose.yml index 90fc002..bff1708 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: kdc: - image: alpine:latest + image: plt-kafka-kdc:latest + pull_policy: never + build: + context: ./docker/kerberos container_name: kdc ports: - '8000:88/tcp' diff --git a/docker/kerberos/Dockerfile b/docker/kerberos/Dockerfile new file mode 100644 index 0000000..d12ae0b --- /dev/null +++ b/docker/kerberos/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:25.04 +RUN apt-get update && apt-get install -y krb5-kdc krb5-admin-server && rm -rf /var/lib/apt/lists/* diff --git a/docker/kerberos/init.sh b/docker/kerberos/init.sh index 0241222..56627e8 100644 --- a/docker/kerberos/init.sh +++ b/docker/kerberos/init.sh @@ -3,13 +3,12 @@ set -e # Setup KDC if needed if [ ! -f /var/lib/krb5kdc/principal ]; then - echo "Setting up KDC ..." - apk add --no-cache krb5-server krb5 + echo "Setting up KDC ..." kdb5_util create -s -P password # # ACL file - echo "*/admin@EXAMPLE.COM *" > /var/lib/krb5kdc/kadm5.acl + echo "*/admin@EXAMPLE.COM *" > /etc/krb5kdc/kadm5.acl # Create principals kadmin.local -q "addprinc -pw admin admin@EXAMPLE.COM" # Main administrator @@ -17,9 +16,13 @@ if [ ! -f /var/lib/krb5kdc/principal ]; then kadmin.local -q "addprinc -randkey admin-keytab@EXAMPLE.COM" # Client with keytab kadmin.local -q "addprinc -pw admin admin-password@EXAMPLE.COM" # Client with password - # Genera keytab + # Generate keytabs kadmin.local -q "ktadd -k /data/broker.keytab broker/broker-sasl-kerberos@EXAMPLE.COM" - kadmin.local -q "ktadd -k /data/admin.keytab admin-keytab@EXAMPLE.COM" + kadmin.local -q "ktadd -k /data/admin.keytab admin-keytab@EXAMPLE.COM" + + # Allow other containers to read the keytab files + chown -R ubuntu:ubuntu /data + chmod -R 755 /data fi krb5kdc diff --git a/test/fixtures/kerberos-authenticator.ts b/test/fixtures/kerberos-authenticator.ts index 63e8a20..cade9af 100644 --- a/test/fixtures/kerberos-authenticator.ts +++ b/test/fixtures/kerberos-authenticator.ts @@ -94,7 +94,6 @@ function performChallenge ( return } - // Altrimenti continua normalmente performChallenge(connection, authenticate, client, response.authBytes.toString('base64'), callback) }) }) From 353d153d9225f01c6cce8aba6040bdf2898f50d4 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Tue, 2 Dec 2025 15:49:29 +0100 Subject: [PATCH 5/8] fixup Signed-off-by: Paolo Insogna --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b55e7c2..adfecb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: uses: pnpm/action-setup@v4 with: version: latest + - name: Install kinit for Kerberos + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y krb5-user - name: Install dependencies run: pnpm install --frozen-lockfile - name: Start Kafka (${{ matrix.confluent-kafka-version }}) Cluster From 88fe534da55f713b6a2d62d9e933de47710e59c6 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Wed, 3 Dec 2025 12:43:30 +0100 Subject: [PATCH 6/8] fixup Signed-off-by: Paolo Insogna --- src/protocol/sasl/utils.ts | 28 ++++-- test/clients/base/sasl-gssapi.test.ts | 2 +- test/fixtures/kerberos-authenticator.ts | 116 ++++++++++++------------ 3 files changed, 77 insertions(+), 69 deletions(-) diff --git a/src/protocol/sasl/utils.ts b/src/protocol/sasl/utils.ts index f850578..5153cd3 100644 --- a/src/protocol/sasl/utils.ts +++ b/src/protocol/sasl/utils.ts @@ -1,18 +1,28 @@ -import { type Callback } from '../../apis/index.ts' +import { type CallbackWithPromise, createPromisifiedCallback, kCallbackPromise } from '../../apis/index.ts' import { AuthenticationError } from '../../errors.ts' import { type SASLCredentialProvider } from '../../network/connection.ts' export function getCredential ( label: string, credentialOrProvider: string | SASLCredentialProvider, - callback: Callback -): void { + callback: CallbackWithPromise +): void +export function getCredential (label: string, credentialOrProvider: string | SASLCredentialProvider): Promise +export function getCredential ( + label: string, + credentialOrProvider: string | SASLCredentialProvider, + callback?: CallbackWithPromise +): void | Promise { + if (!callback) { + callback = createPromisifiedCallback() + } + if (typeof credentialOrProvider === 'string') { callback(null, credentialOrProvider) - return + return callback[kCallbackPromise] } else if (typeof credentialOrProvider !== 'function') { callback(new AuthenticationError(`The ${label} should be a string or a function.`), undefined as unknown as string) - return + return callback[kCallbackPromise] } try { @@ -20,14 +30,14 @@ export function getCredential ( if (typeof credential === 'string') { callback(null, credential) - return + return callback[kCallbackPromise] } else if (typeof (credential as Promise)?.then !== 'function') { callback( new AuthenticationError(`The ${label} provider should return a string or a promise that resolves to a string.`), undefined as unknown as string ) - return + return callback[kCallbackPromise] } credential @@ -39,7 +49,7 @@ export function getCredential ( undefined as unknown as string ) - return + return callback[kCallbackPromise] } process.nextTick(callback, null, token) @@ -56,4 +66,6 @@ export function getCredential ( undefined as unknown as string ) } + + return callback[kCallbackPromise] } diff --git a/test/clients/base/sasl-gssapi.test.ts b/test/clients/base/sasl-gssapi.test.ts index 90a6f7c..86935f0 100644 --- a/test/clients/base/sasl-gssapi.test.ts +++ b/test/clients/base/sasl-gssapi.test.ts @@ -42,7 +42,7 @@ test('should connect to SASL protected broker using SASL/GSSAPI using a custom a mechanism: 'GSSAPI', username: 'admin-password@EXAMPLE.COM', password: 'admin', - authenticate: await createAuthenticator('admin-password@EXAMPLE.COM', 'admin', 'EXAMPLE.COM', 'localhost:8000') + authenticate: await createAuthenticator('broker@broker-sasl-kerberos', 'EXAMPLE.COM', 'localhost:8000') } }) diff --git a/test/fixtures/kerberos-authenticator.ts b/test/fixtures/kerberos-authenticator.ts index cade9af..73151bc 100644 --- a/test/fixtures/kerberos-authenticator.ts +++ b/test/fixtures/kerberos-authenticator.ts @@ -1,9 +1,8 @@ import krb, { type KerberosClient } from 'kerberos' import { execSync } from 'node:child_process' -import { rm } from 'node:fs' -import { mkdtemp, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { resolve as resolvePaths } from 'node:path' import { AuthenticationError, EMPTY_BUFFER, @@ -76,7 +75,6 @@ function performChallenge ( return } - // Invia la risposta wrappata a Kafka authenticate(connection, Buffer.from(wrapped, 'base64'), (error, response) => { if (error) { callback( @@ -99,14 +97,14 @@ function performChallenge ( }) } -function restoreEnvironment ( +async function restoreEnvironment ( callback: Callback, kerberosRoot: string, originalKrb5Config: string | undefined, originalKrbCCName: string | undefined, error: Error | null, response: SaslAuthenticateResponse -): void { +): Promise { if (typeof originalKrb5Config !== 'undefined') { process.env.KRB5_CONFIG = originalKrb5Config } else { @@ -119,12 +117,11 @@ function restoreEnvironment ( delete process.env.KRB5CCNAME } - rm(kerberosRoot, { recursive: true, force: true }, () => { - callback(error, response) - }) + await rm(kerberosRoot, { recursive: true, force: true }) + callback(error, response) } -function authenticate ( +async function authenticate ( service: string, kerberosRoot: string, _m: SASLMechanismValue, @@ -134,74 +131,77 @@ function authenticate ( passwordProvider: string | SASLCredentialProvider | undefined, _t: string | SASLCredentialProvider | undefined, callback: CallbackWithPromise -): void { - saslUtils.getCredential('SASL/GSSAPI username', usernameProvider!, (error, username) => { - if (error) { - callback!(error, undefined as unknown as SaslAuthenticateResponse) - return - } +): Promise { + const afterRestoreCallback = restoreEnvironment.bind( + null, + callback, + kerberosRoot, + process.env.KRB5_CONFIG, + process.env.KRB5CCNAME + ) - saslUtils.getCredential('SASL/GSSAPI password', passwordProvider!, (error, password) => { - if (error) { - callback!(error, undefined as unknown as SaslAuthenticateResponse) - return - } + try { + const username = await saslUtils.getCredential('SASL/GSSAPI username', usernameProvider!) + let password = await saslUtils.getCredential('SASL/GSSAPI password', passwordProvider!) - const afterRestoreCallback = restoreEnvironment.bind( - null, - callback!, - kerberosRoot, - process.env.KRB5_CONFIG, - process.env.KRB5CCNAME - ) - process.env.KRB5_CONFIG = `${kerberosRoot}/krb5.conf` - process.env.KRB5CCNAME = `${kerberosRoot}/krb5.cache` + process.env.KRB5_CONFIG = `${kerberosRoot}/krb5.conf` + process.env.KRB5CCNAME = `${kerberosRoot}/krb5.cache` + + // Using a password + if (!password.startsWith('keytab:')) { + // On MIT Kerberos, kinit does not support reading password from stdin or a password file + // so we convert it to a keytab file if needed using ktutil + if (process.platform !== 'darwin') { + execSync(`ktutil --keytab ${kerberosRoot}/keytab`, { + input: `addent -password -p ${username} -k 1 -f \n${password}\nwkt ${kerberosRoot}/keytab\nquit\n` + }) - let args = '' - if (password.startsWith('keytab:')) { - args = `-kt ${password.substring('keytab:'.length)}` + password = `keytab:${kerberosRoot}/keytab` } else { - args = `--password-file=${kerberosRoot}/password` + // On MacOS, we can use a password file directly since it uses Heimdal Kerberos + await writeFile(`${kerberosRoot}/password`, password, 'utf-8') } + } + + let args = '' + if (password.startsWith('keytab:')) { + args = `-kt ${password.slice(7)}` + } else { + args = `--password-file=${kerberosRoot}/password` + } + + // Import the password via kinit + execSync(`kinit ${args} ${username}`, { stdio: 'pipe', env: process.env }) - // Import the password via kinit - try { - execSync(`kinit ${args} ${username}`, { stdio: 'pipe' }) - } catch (error) { - afterRestoreCallback( - new AuthenticationError('Cannot execute kinit to import user Kerberos credentials', { error }), + krb.initializeClient(service, {}, (error: string | null, client: KerberosClient) => { + if (error) { + callback( + createKerberosAuthenticationError('Cannot initialize Kerberos client.', error), undefined as unknown as SaslAuthenticateResponse ) return } - krb.initializeClient(service, { principal: username }, (error: string | null, client: KerberosClient) => { - if (error) { - callback( - createKerberosAuthenticationError('Cannot initialize Kerberos client.', error), - undefined as unknown as SaslAuthenticateResponse - ) - return - } - - performChallenge(connection, authenticate, client, '', afterRestoreCallback) - }) + performChallenge(connection, authenticate, client, '', afterRestoreCallback) }) - }) + } catch (error) { + await afterRestoreCallback(error, undefined as unknown as SaslAuthenticateResponse) + } } export async function createAuthenticator ( - _u: string, - password: string, + service: string, realm: string, kdc: string ): Promise { - const tmpDir = await mkdtemp(resolve(tmpdir(), 'sasl-gssapi-')) + const tmpDir = await mkdtemp(resolvePaths(tmpdir(), 'sasl-gssapi-')) + // We disable shortname qualification to avoid issues with domain-less hostnames on CI await writeFile( `${tmpDir}/krb5.conf`, ` [libdefaults] + qualify_shortname = "" default_realm = ${realm} default_ccache_name = FILE:${tmpDir}/krb5.cache @@ -217,9 +217,5 @@ export async function createAuthenticator ( 'utf-8' ) - if (!password.startsWith('keytab:')) { - await writeFile(`${tmpDir}/password`, password, 'utf-8') - } - - return authenticate.bind(null, 'broker@broker-sasl-kerberos', tmpDir) + return authenticate.bind(null, service, tmpDir) } From 4fa66c018ed491970a3c4466ca8f319fe4214120 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Wed, 3 Dec 2025 12:54:57 +0100 Subject: [PATCH 7/8] fixup Signed-off-by: Paolo Insogna --- test/network/connection.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/network/connection.test.ts b/test/network/connection.test.ts index fd98ddc..ed2008d 100644 --- a/test/network/connection.test.ts +++ b/test/network/connection.test.ts @@ -979,7 +979,7 @@ for (const mechanism of allowedSASLMechanisms) { mechanism, username: 'admin-password@EXAMPLE.COM', password: 'admin', - authenticate: await createAuthenticator('admin-password@EXAMPLE.COM', 'admin', 'EXAMPLE.COM', 'localhost:8000') + authenticate: await createAuthenticator('broker@broker-sasl-kerberos', 'EXAMPLE.COM', 'localhost:8000') } break default: @@ -1004,12 +1004,7 @@ for (const mechanism of allowedSASLMechanisms) { ? parseBroker(kafkaSaslKerberosBootstrapServers[0]) : parseBroker(kafkaSaslBootstrapServers[0]) - const gssapiAuthenticate = await createAuthenticator( - 'admin-password@EXAMPLE.COM', - 'admin', - 'EXAMPLE.COM', - 'localhost:8000' - ) + const gssapiAuthenticate = await createAuthenticator('broker@broker-sasl-kerberos', 'EXAMPLE.COM', 'localhost:8000') sasl.authenticate = async function customSaslAuthenticate ( mechanism: SASLMechanismValue, From 9f146aa38dc3c680ea5ae6f8f5bad8df5fe07a53 Mon Sep 17 00:00:00 2001 From: Paolo Insogna Date: Fri, 5 Dec 2025 10:15:25 +0000 Subject: [PATCH 8/8] chore: Bumped v1.22.0-alpha.1. Signed-off-by: Paolo Insogna --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5e90e86..90b237c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@platformatic/kafka", - "version": "1.21.0", + "version": "1.22.0-alpha.1", "description": "Modern and performant client for Apache Kafka", "homepage": "https://github.com/platformatic/kafka", "author": "Platformatic Inc. (https://platformatic.dev)", @@ -84,4 +84,4 @@ "engines": { "node": ">= 20.19.4 || >= 22.18.0 || >= 24.6.0" } -} +} \ No newline at end of file