From 59aea21a1cf192d15f16c037f1f2914e4d7926d5 Mon Sep 17 00:00:00 2001 From: "richard.orilla" Date: Fri, 20 Jan 2023 18:11:33 +0800 Subject: [PATCH] feat: add AAD authentication support to connection strings Authentication now uses the standard values defined on [.NET Platform Extension 7](https://learn.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.connectionstring?view=dotnet-plat-ext-7.0). --- README.md | 39 ++++ lib/base/connection-pool.js | 77 +++++++- lib/tedious/connection-pool.js | 97 +++++----- test/common/unit.js | 314 +++++++++++++++++++++++++++++++++ 4 files changed, 479 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 63f2fa27..9a69c867 100644 --- a/README.md +++ b/README.md @@ -574,11 +574,50 @@ In addition to configuration object there is an option to pass config as a conne ##### Classic Connection String +###### Standard configuration using tedious driver + ``` Server=localhost,1433;Database=database;User Id=username;Password=password;Encrypt=true +``` +###### Standard configuration using msnodesqlv8 driver +``` Driver=msnodesqlv8;Server=(local)\INSTANCE;Database=database;UID=DOMAIN\username;PWD=password;Encrypt=true ``` +##### Azure Active Directory Authentication Connection String + +Several types of Azure Authentication are supported: + +###### Authentication using Active Directory Integrated +``` +Server=*.database.windows.net;Database=database;Authentication=Active Directory Integrated;Client secret=clientsecret;Client Id=clientid;Tenant Id=tenantid;Encrypt=true +``` +Note: Internally, the 'Active Directory Integrated' will change its type depending on the other parameters you add to it. On the example above, it will change to azure-active-directory-service-principal-secret because we supplied a Client Id, Client secret and Tenant Id. + +If you want to utilize Authentication tokens (azure-active-directory-access-token) Just remove the unnecessary additional parameters and supply only a token parameter, such as in this example: + +``` +Server=*.database.windows.net;Database=database;Authentication=Active Directory Integrated;token=token;Encrypt=true +``` + +Finally if you want to utilize managed identity services such as managed identity service app service you can follow this example below: +``` +Server=*.database.windows.net;Database=database;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;msi secret=msisecret;Encrypt=true +``` +or if its managed identity service virtual machines, then follow this: +``` +Server=*.database.windows.net;Database=database;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;Encrypt=true +``` + +We can also utilizes Active Directory Password but unlike the previous examples, it is not part of the Active Directory Integrated Authentication. + +###### Authentication using Active Directory Password +``` +Server=*.database.windows.net;Database=database;Authentication=Active Directory Password;User Id=username;Password=password;Client Id=clientid;Tenant Id=tenantid;Encrypt=true +``` + +For more reference, you can consult [here](https://tediousjs.github.io/tedious/api-connection.html#function_newConnection). Under the authentication.type parameter. + ## Drivers ### Tedious diff --git a/lib/base/connection-pool.js b/lib/base/connection-pool.js index 6dcc6fa9..80b151a3 100644 --- a/lib/base/connection-pool.js +++ b/lib/base/connection-pool.js @@ -99,6 +99,28 @@ class ConnectionPool extends EventEmitter { return this._parseConnectionString(connectionString) } + static _parseAuthenticationType (type, entries) { + switch (type.toLowerCase()) { + case 'active directory integrated': + if (entries.includes('token')) { + return 'azure-active-directory-access-token' + } else if (['client id', 'client secret', 'tenant id'].every(entry => entries.includes(entry))) { + return 'azure-active-directory-service-principal-secret' + } else if (['client id', 'msi endpoint', 'msi secret'].every(entry => entries.includes(entry))) { + return 'azure-active-directory-msi-app-service' + } else if (['client id', 'msi endpoint'].every(entry => entries.includes(entry))) { + return 'azure-active-directory-msi-vm' + } + return 'azure-active-directory-default' + case 'active directory password': + return 'azure-active-directory-password' + case 'ntlm': + return 'ntlm' + default: + return 'default' + } + } + static _parseConnectionString (connectionString) { const parsed = parseSqlConnectionString(connectionString, true, true) return Object.entries(parsed).reduce((config, [key, value]) => { @@ -115,6 +137,9 @@ class ConnectionPool extends EventEmitter { case 'attachdbfilename': break case 'authentication': + Object.assign(config, { + authentication_type: this._parseAuthenticationType(value, Object.keys(parsed)) + }) break case 'column encryption setting': break @@ -134,6 +159,16 @@ class ConnectionPool extends EventEmitter { break case 'context connection': break + case 'client id': + Object.assign(config, { + clientId: value + }) + break + case 'client secret': + Object.assign(config, { + clientSecret: value + }) + break case 'current language': Object.assign(config.options, { language: value @@ -173,9 +208,11 @@ class ConnectionPool extends EventEmitter { port, server }) - Object.assign(config.options, { - instanceName - }) + if (instanceName) { + Object.assign(config.options, { + instanceName + }) + } break } case 'encrypt': @@ -204,6 +241,16 @@ class ConnectionPool extends EventEmitter { min: value }) break + case 'msi endpoint': + Object.assign(config, { + msiEndpoint: value + }) + break + case 'msi secret': + Object.assign(config, { + msiSecret: value + }) + break case 'multipleactiveresultsets': break case 'multisubnetfailover': @@ -231,6 +278,16 @@ class ConnectionPool extends EventEmitter { break case 'replication': break + case 'tenant id': + Object.assign(config, { + tenantId: value + }) + break + case 'token': + Object.assign(config, { + token: value + }) + break case 'transaction binding': Object.assign(config.options, { enableImplicitTransactions: value.toLowerCase() === 'implicit unbind' @@ -253,10 +310,16 @@ class ConnectionPool extends EventEmitter { domain = domainUser[1] user = domainUser[2] } - Object.assign(config, { - domain, - user - }) + if (domain) { + Object.assign(config, { + domain + }) + } + if (user) { + Object.assign(config, { + user + }) + } break } case 'user instance': diff --git a/lib/tedious/connection-pool.js b/lib/tedious/connection-pool.js index d3c8ec18..014c0d4f 100644 --- a/lib/tedious/connection-pool.js +++ b/lib/tedious/connection-pool.js @@ -8,6 +8,61 @@ const shared = require('../shared') const ConnectionError = require('../error/connection-error') class ConnectionPool extends BaseConnectionPool { + _config () { + const cfg = { + server: this.config.server, + options: Object.assign({ + encrypt: typeof this.config.encrypt === 'boolean' ? this.config.encrypt : true, + trustServerCertificate: typeof this.config.trustServerCertificate === 'boolean' ? this.config.trustServerCertificate : false + }, this.config.options), + authentication: Object.assign({ + type: this.config.domain !== undefined ? 'ntlm' : this.config.authentication_type !== undefined ? this.config.authentication_type : 'default', + options: Object.entries({ + userName: this.config.user, + password: this.config.password, + domain: this.config.domain, + clientId: this.config.clientId, + clientSecret: this.config.clientSecret, + tenantId: this.config.tenantId, + token: this.config.token, + msiEndpoint: this.config.msiEndpoint, + msiSecret: this.config.msiSecret + }).reduce((acc, [key, val]) => { + if (typeof val !== 'undefined') { + return { ...acc, [key]: val } + } + return acc + }, {}) + }, this.config.authentication) + } + + cfg.options.database = cfg.options.database || this.config.database + cfg.options.port = cfg.options.port || this.config.port + cfg.options.connectTimeout = cfg.options.connectTimeout ?? this.config.connectionTimeout ?? this.config.timeout ?? 15000 + cfg.options.requestTimeout = cfg.options.requestTimeout ?? this.config.requestTimeout ?? this.config.timeout ?? 15000 + cfg.options.tdsVersion = cfg.options.tdsVersion || '7_4' + cfg.options.rowCollectionOnDone = cfg.options.rowCollectionOnDone || false + cfg.options.rowCollectionOnRequestCompletion = cfg.options.rowCollectionOnRequestCompletion || false + cfg.options.useColumnNames = cfg.options.useColumnNames || false + cfg.options.appName = cfg.options.appName || 'node-mssql' + + // tedious always connect via tcp when port is specified + if (cfg.options.instanceName) delete cfg.options.port + + if (isNaN(cfg.options.requestTimeout)) cfg.options.requestTimeout = 15000 + if (cfg.options.requestTimeout === Infinity || cfg.options.requestTimeout < 0) cfg.options.requestTimeout = 0 + + if (!cfg.options.debug && this.config.debug) { + cfg.options.debug = { + packet: true, + token: true, + data: true, + payload: true + } + } + return cfg + } + _poolCreate () { return new shared.Promise((resolve, reject) => { const resolveOnce = (v) => { @@ -18,49 +73,9 @@ class ConnectionPool extends BaseConnectionPool { reject(e) resolve = reject = () => {} } - const cfg = { - server: this.config.server, - options: Object.assign({ - encrypt: typeof this.config.encrypt === 'boolean' ? this.config.encrypt : true, - trustServerCertificate: typeof this.config.trustServerCertificate === 'boolean' ? this.config.trustServerCertificate : false - }, this.config.options), - authentication: Object.assign({ - type: this.config.domain !== undefined ? 'ntlm' : 'default', - options: { - userName: this.config.user, - password: this.config.password, - domain: this.config.domain - } - }, this.config.authentication) - } - - cfg.options.database = cfg.options.database || this.config.database - cfg.options.port = cfg.options.port || this.config.port - cfg.options.connectTimeout = cfg.options.connectTimeout ?? this.config.connectionTimeout ?? this.config.timeout ?? 15000 - cfg.options.requestTimeout = cfg.options.requestTimeout ?? this.config.requestTimeout ?? this.config.timeout ?? 15000 - cfg.options.tdsVersion = cfg.options.tdsVersion || '7_4' - cfg.options.rowCollectionOnDone = cfg.options.rowCollectionOnDone || false - cfg.options.rowCollectionOnRequestCompletion = cfg.options.rowCollectionOnRequestCompletion || false - cfg.options.useColumnNames = cfg.options.useColumnNames || false - cfg.options.appName = cfg.options.appName || 'node-mssql' - - // tedious always connect via tcp when port is specified - if (cfg.options.instanceName) delete cfg.options.port - - if (isNaN(cfg.options.requestTimeout)) cfg.options.requestTimeout = 15000 - if (cfg.options.requestTimeout === Infinity || cfg.options.requestTimeout < 0) cfg.options.requestTimeout = 0 - - if (!cfg.options.debug && this.config.debug) { - cfg.options.debug = { - packet: true, - token: true, - data: true, - payload: true - } - } let tedious try { - tedious = new tds.Connection(cfg) + tedious = new tds.Connection(this._config()) } catch (err) { rejectOnce(err) return diff --git a/test/common/unit.js b/test/common/unit.js index 03418ad2..bf25b21c 100644 --- a/test/common/unit.js +++ b/test/common/unit.js @@ -6,6 +6,7 @@ const sql = require('../../') const assert = require('assert') const udt = require('../../lib/udt') const BasePool = require('../../lib/base/connection-pool') +const ConnectionPool = require('../../lib/tedious/connection-pool') describe('Unit', () => { it('table', done => { @@ -422,3 +423,316 @@ describe('connection string parser', () => { }) }) }) + +describe('connection string auth - base', () => { + it('parses basic login', () => { + const config = BasePool._parseConnectionString('Server=database.test.com;Database=test;User Id=test;Password=admin') + assert.deepEqual(config, { + database: 'test', + options: {}, + password: 'admin', + pool: {}, + port: 1433, + server: 'database.test.com', + user: 'test' + }) + }) + + it('parses basic login with explicit Sql Password as Authentication', () => { + const config = BasePool._parseConnectionString('Authentication=Sql Password;Server=database.test.com;Database=test;User Id=test;Password=admin') + assert.deepEqual(config, { + authentication_type: 'default', + database: 'test', + options: {}, + password: 'admin', + pool: {}, + port: 1433, + server: 'database.test.com', + user: 'test' + }) + }) + + it('parses active directory password', () => { + const config = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Password;User Id=username;Password=password;Client Id=clientid;Tenant Id=tenantid;Encrypt=true') + assert.deepEqual(config, { + authentication_type: 'azure-active-directory-password', + database: 'test', + options: { + encrypt: true + }, + password: 'password', + pool: {}, + port: 1433, + server: '*.database.windows.net', + user: 'username', + clientId: 'clientid', + tenantId: 'tenantid' + }) + }) + + it('parses active directory integrated token authentication', () => { + const config = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;token=token;Encrypt=true') + assert.deepEqual(config, { + authentication_type: 'azure-active-directory-access-token', + database: 'test', + options: { + encrypt: true + }, + pool: {}, + port: 1433, + server: '*.database.windows.net', + token: 'token' + }) + }) + + it('parses active directory integrated client secret authentication', () => { + const config = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;Client secret=clientsecret;Client Id=clientid;Tenant Id=tenantid;Encrypt=true') + assert.deepEqual(config, { + authentication_type: 'azure-active-directory-service-principal-secret', + database: 'test', + options: { + encrypt: true + }, + pool: {}, + port: 1433, + server: '*.database.windows.net', + clientId: 'clientid', + tenantId: 'tenantid', + clientSecret: 'clientsecret' + }) + }) + + it('parses active directory integrated managed service identity app vm', () => { + const config = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;Encrypt=true') + assert.deepEqual(config, { + authentication_type: 'azure-active-directory-msi-vm', + database: 'test', + options: { + encrypt: true + }, + pool: {}, + port: 1433, + server: '*.database.windows.net', + clientId: 'clientid', + msiEndpoint: 'msiendpoint' + }) + }) + + it('parses active directory integrated managed service identity app service', () => { + const config = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;msi secret=msisecret;Encrypt=true') + assert.deepEqual(config, { + authentication_type: 'azure-active-directory-msi-app-service', + database: 'test', + options: { + encrypt: true + }, + pool: {}, + port: 1433, + server: '*.database.windows.net', + clientId: 'clientid', + msiEndpoint: 'msiendpoint', + msiSecret: 'msisecret' + }) + }) +}) + +describe('connection string auth - tedious', () => { + it('parses basic login', () => { + const baseConfig = BasePool._parseConnectionString('Server=database.test.com;Database=test;User Id=test;Password=admin') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: 'database.test.com', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'default', + options: { + userName: 'test', + password: 'admin' + } + } + }) + }) + + it('parses basic login with explicit Sql Password as Authentication', () => { + const baseConfig = BasePool._parseConnectionString('Authentication=Sql Password;Server=database.test.com;Database=test;User Id=test;Password=admin') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: 'database.test.com', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'default', + options: { + userName: 'test', + password: 'admin' + } + } + }) + }) + + it('parses active directory password', () => { + const baseConfig = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Password;User Id=username;Password=password;Client Id=clientid;Tenant Id=tenantid;Encrypt=true') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: '*.database.windows.net', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'azure-active-directory-password', + options: { + userName: 'username', + password: 'password', + clientId: 'clientid', + tenantId: 'tenantid' + } + } + }) + }) + + it('parses active directory integrated token authentication', () => { + const baseConfig = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;token=token;Encrypt=true') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: '*.database.windows.net', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'azure-active-directory-access-token', + options: { + token: 'token' + } + } + }) + }) + + it('parses active directory integrated client secret authentication', () => { + const baseConfig = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;Client secret=clientsecret;Client Id=clientid;Tenant Id=tenantid;Encrypt=true') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: '*.database.windows.net', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'azure-active-directory-service-principal-secret', + options: { + clientId: 'clientid', + clientSecret: 'clientsecret', + tenantId: 'tenantid' + } + } + }) + }) + + it('parses active directory integrated managed service identity app vm', () => { + const baseConfig = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;Encrypt=true') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: '*.database.windows.net', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'azure-active-directory-msi-vm', + options: { + clientId: 'clientid', + msiEndpoint: 'msiendpoint' + } + } + }) + }) + + it('parses active directory integrated managed service identity app service', () => { + const baseConfig = BasePool._parseConnectionString('Server=*.database.windows.net;Database=test;Authentication=Active Directory Integrated;msi endpoint=msiendpoint;Client Id=clientid;msi secret=msisecret;Encrypt=true') + const config = new ConnectionPool(baseConfig)._config() + assert.deepEqual(config, { + server: '*.database.windows.net', + options: { + encrypt: true, + trustServerCertificate: false, + database: 'test', + port: 1433, + connectTimeout: 15000, + requestTimeout: 15000, + tdsVersion: '7_4', + rowCollectionOnDone: false, + rowCollectionOnRequestCompletion: false, + useColumnNames: false, + appName: 'node-mssql' + }, + authentication: { + type: 'azure-active-directory-msi-app-service', + options: { + clientId: 'clientid', + msiEndpoint: 'msiendpoint', + msiSecret: 'msisecret' + } + } + }) + }) +})