diff --git a/packages/cli-repl/src/arg-mapper.spec.ts b/packages/cli-repl/src/arg-mapper.spec.ts index 531be51b28..b667315cc8 100644 --- a/packages/cli-repl/src/arg-mapper.spec.ts +++ b/packages/cli-repl/src/arg-mapper.spec.ts @@ -208,6 +208,66 @@ describe('arg-mapper.mapCliToDriver', () => { }); }); + context('when the cli args have gssapiServiceName', () => { + const cliOptions: CliOptions = { gssapiServiceName: 'alternate' }; + + it('maps to authMechanismProperties.SERVICE_NAME', async() => { + expect(await mapCliToDriver(cliOptions)).to.deep.equal({ + authMechanismProperties: { + SERVICE_NAME: 'alternate' + } + }); + }); + }); + + context('when the cli args have sspiRealmOverride', () => { + const cliOptions: CliOptions = { sspiRealmOverride: 'REALM.COM' }; + + it('maps to authMechanismProperties.SERVICE_REALM', async() => { + expect(await mapCliToDriver(cliOptions)).to.deep.equal({ + authMechanismProperties: { + SERVICE_REALM: 'REALM.COM' + } + }); + }); + }); + + context('when the cli args have sspiHostnameCanonicalization', () => { + context('with a value of none', () => { + const cliOptions: CliOptions = { sspiHostnameCanonicalization: 'none' }; + + it('is not mapped to authMechanismProperties', async() => { + expect(await mapCliToDriver(cliOptions)).to.deep.equal({}); + }); + }); + + context('with a value of forward', () => { + const cliOptions: CliOptions = { sspiHostnameCanonicalization: 'forward' }; + + it('is mapped to authMechanismProperties', async() => { + expect(await mapCliToDriver(cliOptions)).to.deep.equal({ + authMechanismProperties: { + gssapiCanonicalizeHostName: 'true' + } + }); + }); + }); + + context('with a value of forwardAndReverse', () => { + const cliOptions: CliOptions = { sspiHostnameCanonicalization: 'forwardAndReverse' }; + + it('is mapped to authMechanismProperties', async() => { + try { + await mapCliToDriver(cliOptions); + } catch (e) { + expect(e.message).to.contain('forwardAndReverse is not supported'); + return; + } + expect.fail('expected error'); + }); + }); + }); + context('when the cli args have keyVaultNamespace', () => { const cliOptions: CliOptions = { keyVaultNamespace: 'db.datakeys' }; diff --git a/packages/cli-repl/src/arg-mapper.ts b/packages/cli-repl/src/arg-mapper.ts index ad767e88bd..79aa8d6265 100644 --- a/packages/cli-repl/src/arg-mapper.ts +++ b/packages/cli-repl/src/arg-mapper.ts @@ -1,4 +1,4 @@ -import { MongoshInvalidInputError, MongoshUnimplementedError } from '@mongosh/errors'; +import { CommonErrors, MongoshInvalidInputError, MongoshUnimplementedError } from '@mongosh/errors'; import { CliOptions, MongoClientOptions } from '@mongosh/service-provider-server'; import setValue from 'lodash.set'; @@ -13,6 +13,9 @@ const MAPPINGS = { awsSecretAccessKey: 'autoEncryption.kmsProviders.aws.secretAccessKey', awsSessionToken: 'autoEncryption.kmsProviders.aws.sessionToken', awsIamSessionToken: 'authMechanismProperties.AWS_SESSION_TOKEN', + gssapiServiceName: 'authMechanismProperties.SERVICE_NAME', + sspiRealmOverride: 'authMechanismProperties.SERVICE_REALM', + sspiHostnameCanonicalization: { opt: 'authMechanismProperties.gssapiCanonicalizeHostName', fun: mapSspiHostnameCanonicalization }, authenticationDatabase: 'authSource', authenticationMechanism: 'authMechanism', keyVaultNamespace: 'autoEncryption.keyVaultNamespace', @@ -52,8 +55,16 @@ async function mapCliToDriver(options: CliOptions): Promise if (typeof mapping === 'object') { const cliValue = (options as any)[cliOption]; if (cliValue) { - const { opt, val } = mapping; - setValue(nodeOptions, opt, val); + let newValue: any; + if ('val' in mapping) { + newValue = mapping.val; + } else { + newValue = mapping.fun(cliValue); + if (newValue === undefined) { + return; + } + } + setValue(nodeOptions, mapping.opt, newValue); } } else { setValue(nodeOptions, mapping, (options as any)[cliOption]); @@ -112,4 +123,17 @@ function getCertificateExporter(): TlsCertificateExporter | undefined { return undefined; } +function mapSspiHostnameCanonicalization(value: string): string | undefined { + if (!value || value === 'none') { + return undefined; + } + if (value === 'forward') { + return 'true'; + } + throw new MongoshInvalidInputError( + `--sspiHostnameCanonicalization value ${value} is not supported`, + CommonErrors.InvalidArgument + ); +} + export default mapCliToDriver; diff --git a/packages/cli-repl/src/arg-parser.spec.ts b/packages/cli-repl/src/arg-parser.spec.ts index 060256b20f..addd3835de 100644 --- a/packages/cli-repl/src/arg-parser.spec.ts +++ b/packages/cli-repl/src/arg-parser.spec.ts @@ -322,12 +322,47 @@ describe('arg-parser', () => { context('when providing --gssapiHostName', () => { const argv = [ ...baseArgv, uri, '--gssapiHostName', 'example.com' ]; + it('throws an error since it is not yet supported', () => { + try { + parseCliArgs(argv); + } catch (e) { + expect(e).to.be.instanceOf(MongoshUnimplementedError); + expect(e.message).to.include('Argument --gssapiHostName is not yet supported in mongosh'); + return; + } + expect.fail('Expected error'); + }); + + // it('returns the URI in the object', () => { + // expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + // }); + + // it('sets the gssapiHostName in the object', () => { + // expect(parseCliArgs(argv).gssapiHostName).to.equal('example.com'); + // }); + }); + + context('when providing --sspiHostnameCanonicalization', () => { + const argv = [ ...baseArgv, uri, '--sspiHostnameCanonicalization', 'forward' ]; + + it('returns the URI in the object', () => { + expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + }); + + it('sets the gssapiHostName in the object', () => { + expect(parseCliArgs(argv).sspiHostnameCanonicalization).to.equal('forward'); + }); + }); + + context('when providing --sspiRealmOverride', () => { + const argv = [ ...baseArgv, uri, '--sspiRealmOverride', 'example2.com' ]; + it('returns the URI in the object', () => { expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); }); it('sets the gssapiHostName in the object', () => { - expect(parseCliArgs(argv).gssapiHostName).to.equal('example.com'); + expect(parseCliArgs(argv).sspiRealmOverride).to.equal('example2.com'); }); }); diff --git a/packages/cli-repl/src/arg-parser.ts b/packages/cli-repl/src/arg-parser.ts index 589bdd982c..081ad8f148 100644 --- a/packages/cli-repl/src/arg-parser.ts +++ b/packages/cli-repl/src/arg-parser.ts @@ -27,6 +27,8 @@ const OPTIONS = { 'eval', 'gssapiHostName', 'gssapiServiceName', + 'sspiHostnameCanonicalization', + 'sspiRealmOverride', 'host', 'keyVaultNamespace', 'kmsURL', @@ -108,7 +110,8 @@ const DEPRECATED_ARGS_WITH_REPLACEMENT: Record = { */ const UNSUPPORTED_ARGS: Readonly = [ 'sslFIPSMode', - 'tlsFIPSMode' + 'tlsFIPSMode', + 'gssapiHostName' ]; /** diff --git a/packages/cli-repl/src/constants.ts b/packages/cli-repl/src/constants.ts index 72bdd1b395..045de77645 100644 --- a/packages/cli-repl/src/constants.ts +++ b/packages/cli-repl/src/constants.ts @@ -38,6 +38,9 @@ export const USAGE = ` --authenticationDatabase [arg] ${i18n.__('cli-repl.args.authenticationDatabase')} --authenticationMechanism [arg] ${i18n.__('cli-repl.args.authenticationMechanism')} --awsIamSessionToken [arg] ${i18n.__('cli-repl.args.awsIamSessionToken')} + --gssapiServiceName [arg] ${i18n.__('cli-repl.args.gssapiServiceName')} + --sspiHostnameCanonicalization [arg] ${i18n.__('cli-repl.args.sspiHostnameCanonicalization')} + --sspiRealmOverride [arg] ${i18n.__('cli-repl.args.sspiRealmOverride')} ${clr(i18n.__('cli-repl.args.tlsOptions'), ['bold', 'yellow'])} diff --git a/packages/i18n/src/locales/en_US.ts b/packages/i18n/src/locales/en_US.ts index bb26a9763d..98f23ca766 100644 --- a/packages/i18n/src/locales/en_US.ts +++ b/packages/i18n/src/locales/en_US.ts @@ -31,6 +31,8 @@ const translations: Catalog = { awsIamSessionToken: 'AWS IAM Temporary Session Token ID', gssapiServiceName: 'Service name to use when authenticating using GSSAPI/Kerberos', gssapiHostName: 'Remote host name to use for purpose of GSSAPI/Kerberos authentication', + sspiHostnameCanonicalization: 'Specify the SSPI hostname canonicalization (none or forward, available on Windows)', + sspiRealmOverride: 'Specify the SSPI server realm (available on Windows)', tlsOptions: 'TLS Options:', tls: 'Use TLS for all connections', tlsCertificateKeyFile: 'PEM certificate/key file for TLS', @@ -82,7 +84,9 @@ const translations: Catalog = { }, 'uri-generator': { 'no-host-port': 'If a full URI is provided, you cannot also specify --host or --port', - 'invalid-host': 'The --host argument contains an invalid character' + 'invalid-host': 'The --host argument contains an invalid character', + 'diverging-service-name': 'Either the --gssapiServiceName parameter or the SERVICE_NAME authentication mechanism property in the connection string can be used but not both.', + 'gssapi-service-name-unsupported': 'The gssapiServiceName query parameter is not supported anymore. Please use the --gssapiServiceName argument or the SERVICE_NAME authentication mechanism property (e.g. ?authMechanismProperties=SERVICE_NAME:).', } }, 'service-provider-browser': {}, diff --git a/packages/service-provider-core/src/cli-options.ts b/packages/service-provider-core/src/cli-options.ts index acd94b8c2c..e61ece1202 100644 --- a/packages/service-provider-core/src/cli-options.ts +++ b/packages/service-provider-core/src/cli-options.ts @@ -18,8 +18,9 @@ export default interface CliOptions { awsSessionToken?: string; db?: string; eval?: string; - gssapiHostName?: string; gssapiServiceName?: string; + sspiHostnameCanonicalization?: string; + sspiRealmOverride?: string; help?: boolean; host?: string; ipv6?: boolean; diff --git a/packages/service-provider-core/src/uri-generator.spec.ts b/packages/service-provider-core/src/uri-generator.spec.ts index a64d6f4444..dcefdc60d9 100644 --- a/packages/service-provider-core/src/uri-generator.spec.ts +++ b/packages/service-provider-core/src/uri-generator.spec.ts @@ -1,5 +1,6 @@ import { CommonErrors, MongoshInvalidInputError } from '@mongosh/errors'; import { expect } from 'chai'; +import CliOptions from './cli-options'; import generateUri from './uri-generator'; describe('uri-generator.generate-uri', () => { @@ -77,6 +78,34 @@ describe('uri-generator.generate-uri', () => { } }); }); + + context('when providing gssapiServiceName', () => { + context('and the URI does not include SERVICE_NAME in authMechanismProperties', () => { + const uri = 'mongodb+srv://some.host/foo'; + const options: CliOptions = { connectionSpecifier: uri, gssapiServiceName: 'alternate' }; + + it('does not throw an error', () => { + expect(generateUri(options)).to.equal('mongodb+srv://some.host/foo'); + }); + }); + + context('and the URI includes SERVICE_NAME in authMechanismProperties', () => { + const uri = 'mongodb+srv://some.host/foo?authMechanismProperties=SERVICE_NAME:whatever'; + const options: CliOptions = { connectionSpecifier: uri, gssapiServiceName: 'alternate' }; + + it('throws an error', () => { + try { + generateUri(options); + } catch (e) { + expect(e.name).to.equal('MongoshInvalidInputError'); + expect(e.code).to.equal(CommonErrors.InvalidArgument); + expect(e.message).to.contain('--gssapiServiceName parameter or the SERVICE_NAME'); + return; + } + expect.fail('expected error'); + }); + }); + }); }); context('when providing a URI with query parameters', () => { @@ -120,6 +149,23 @@ describe('uri-generator.generate-uri', () => { expect(generateUri(options)).to.equal(uri); }); }); + + context('when providing a URI with the legacy gssapiServiceName query parameter', () => { + const uri = 'mongodb://192.42.42.42:27017,192.0.0.1:27018/db?gssapiServiceName=primary'; + const options = { connectionSpecifier: uri }; + + it('throws an error', () => { + try { + generateUri(options); + } catch (e) { + expect(e.name).to.equal('MongoshInvalidInputError'); + expect(e.code).to.equal(CommonErrors.InvalidArgument); + expect(e.message).to.contain('gssapiServiceName query parameter is not supported'); + return; + } + expect.fail('expected error'); + }); + }); }); context('when a URI is provided without a scheme', () => { diff --git a/packages/service-provider-core/src/uri-generator.ts b/packages/service-provider-core/src/uri-generator.ts index 1d5442d577..bf93434ff3 100644 --- a/packages/service-provider-core/src/uri-generator.ts +++ b/packages/service-provider-core/src/uri-generator.ts @@ -25,36 +25,44 @@ const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = '27017'; /** - * GSSAPI options not supported as options in Node driver, - * only in the URI. + * Conflicting host/port message. */ -// const GSSAPI_HOST_NAME = 'gssapiHostName'; +const CONFLICT = 'cli-repl.uri-generator.no-host-port'; /** - * GSSAPI options not supported as options in Node driver, - * only in the URI. + * Invalid host message. */ -// const GSSAPI_SERVICE_NAME = 'gssapiServiceName'; +const INVALID_HOST = 'cli-repl.uri-generator.invalid-host'; /** - * Conflicting host/port message. + * Diverging gssapiServiceName and SERVICE_NAME mechanism property */ -const CONFLICT = 'cli-repl.uri-generator.no-host-port'; +const DIVERGING_SERVICE_NAME = 'cli-repl.uri-generator.diverging-service-name'; /** - * Invalid host message. + * Usage of unsupported gssapiServiceName query parameter */ -const INVALID_HOST = 'cli-repl.uri-generator.invalid-host'; +const GSSAPI_SERVICE_NAME_UNSUPPORTED = 'cli-repl.uri-generator.gssapi-service-name-unsupported'; /** * Validate conflicts in the options. - * - * @param {CliOptions} options - The options. */ -function validateConflicts(options: CliOptions): void { +function validateConflicts(options: CliOptions, connectionString?: ConnectionString): void { if (options.host || options.port) { throw new MongoshInvalidInputError(i18n.__(CONFLICT), CommonErrors.InvalidArgument); } + + if (options.gssapiServiceName && connectionString?.searchParams.has('authMechanismProperties')) { + const authProperties = connectionString.searchParams.get('authMechanismProperties') ?? ''; + const serviceName = /,?SERVICE_NAME:([^,]+)/.exec(authProperties)?.[1]; + if (serviceName !== undefined && options.gssapiServiceName !== serviceName) { + throw new MongoshInvalidInputError(i18n.__(DIVERGING_SERVICE_NAME), CommonErrors.InvalidArgument); + } + } + + if (connectionString?.searchParams.has('gssapiServiceName')) { + throw new MongoshInvalidInputError(i18n.__(GSSAPI_SERVICE_NAME_UNSUPPORTED), CommonErrors.InvalidArgument); + } } /** @@ -120,9 +128,6 @@ function generatePort(options: CliOptions): string { * only if one of these conditions is met: * - it contains no '.' after the last appearance of '\' or '/' * - it doesn't end in '.js' and it doesn't specify a path to an existing file - * - * gssapiHostName?: string; // needs to go in URI - * gssapiServiceName?: string; // needs to go in URI */ function generateUri(options: CliOptions): string { if (options.nodb) { @@ -160,12 +165,14 @@ function generateUriNormalized(options: CliOptions): ConnectionString { // mongodb+srv:// URI is provided, treat as correct and immediately return if (uri.startsWith(Scheme.MongoSrv)) { - validateConflicts(options); - return new ConnectionString(uri); + const connectionString = new ConnectionString(uri); + validateConflicts(options, connectionString); + return connectionString; } else if (uri.startsWith(Scheme.Mongo)) { // we need to figure out if we have to add the directConnection query parameter - validateConflicts(options); - return addShellConnectionStringParameters(new ConnectionString(uri)); + const connectionString = new ConnectionString(uri); + validateConflicts(options, connectionString); + return addShellConnectionStringParameters(connectionString); } // Capture host, port and db from the string and generate a URI from