From c52a4edfb79934249ea58703b9ddd4a94ef34418 Mon Sep 17 00:00:00 2001 From: Durran Jordan Date: Thu, 4 May 2023 16:37:04 +0200 Subject: [PATCH] feat(NODE-5191): OIDC Auth Updates (#3637) --- .evergreen/config.in.yml | 2 +- .evergreen/config.yml | 2 +- .evergreen/run-oidc-tests.sh | 1 + package.json | 2 +- src/cmap/auth/mongo_credentials.ts | 37 +- src/cmap/auth/mongodb_oidc.ts | 98 +- .../auth/mongodb_oidc/aws_service_workflow.ts | 9 +- src/cmap/auth/mongodb_oidc/cache.ts | 27 + .../auth/mongodb_oidc/callback_lock_cache.ts | 107 ++ .../auth/mongodb_oidc/callback_workflow.ts | 379 +++-- .../auth/mongodb_oidc/service_workflow.ts | 8 +- .../auth/mongodb_oidc/token_entry_cache.ts | 113 +- src/cmap/auth/mongodb_oidc/workflow.ts | 21 - src/connection_string.ts | 11 + src/index.ts | 7 +- src/mongo_client.ts | 29 +- src/utils.ts | 17 + test/manual/mongodb_oidc.prose.test.ts | 1479 ++++++++++++----- test/mongodb.ts | 2 +- .../mongodb_oidc/callback_lock_cache.test.ts | 145 ++ .../mongodb_oidc/token_entry_cache.test.ts | 49 +- test/unit/connection_string.test.ts | 67 + test/unit/utils.test.ts | 143 ++ 23 files changed, 1952 insertions(+), 803 deletions(-) create mode 100644 src/cmap/auth/mongodb_oidc/cache.ts create mode 100644 src/cmap/auth/mongodb_oidc/callback_lock_cache.ts delete mode 100644 src/cmap/auth/mongodb_oidc/workflow.ts create mode 100644 test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts diff --git a/.evergreen/config.in.yml b/.evergreen/config.in.yml index 1ad8a8824b..cbe5edcc5e 100644 --- a/.evergreen/config.in.yml +++ b/.evergreen/config.in.yml @@ -152,7 +152,7 @@ functions: ${PREPARE_SHELL} OIDC_TOKEN_DIR="/tmp/tokens" \ - AWS_WEB_IDENTITY_TOKEN_FILE="/tmp/tokens/test1" \ + AWS_WEB_IDENTITY_TOKEN_FILE="/tmp/tokens/test_user1" \ PROJECT_DIRECTORY="${PROJECT_DIRECTORY}" \ bash ${PROJECT_DIRECTORY}/.evergreen/run-oidc-tests.sh diff --git a/.evergreen/config.yml b/.evergreen/config.yml index d9fa9e999e..a2d3316f41 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -123,7 +123,7 @@ functions: ${PREPARE_SHELL} OIDC_TOKEN_DIR="/tmp/tokens" \ - AWS_WEB_IDENTITY_TOKEN_FILE="/tmp/tokens/test1" \ + AWS_WEB_IDENTITY_TOKEN_FILE="/tmp/tokens/test_user1" \ PROJECT_DIRECTORY="${PROJECT_DIRECTORY}" \ bash ${PROJECT_DIRECTORY}/.evergreen/run-oidc-tests.sh run deployed aws lambda tests: diff --git a/.evergreen/run-oidc-tests.sh b/.evergreen/run-oidc-tests.sh index ee6bd74c26..7254fc16c2 100644 --- a/.evergreen/run-oidc-tests.sh +++ b/.evergreen/run-oidc-tests.sh @@ -10,5 +10,6 @@ MONGODB_URI_SINGLE="${MONGODB_URI}/?authMechanism=MONGODB-OIDC&authMechanismProp echo $MONGODB_URI_SINGLE export MONGODB_URI="$MONGODB_URI_SINGLE" +export OIDC_TOKEN_DIR=${OIDC_TOKEN_DIR} npm run check:oidc diff --git a/package.json b/package.json index 7590e18dec..91cb2e0c06 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "check:atlas": "mocha --config test/manual/mocharc.json test/manual/atlas_connectivity.test.js", "check:adl": "mocha --config test/mocha_mongodb.json test/manual/atlas-data-lake-testing", "check:aws": "nyc mocha --config test/mocha_mongodb.json test/integration/auth/mongodb_aws.test.ts", - "check:oidc": "mocha --config test/manual/mocharc.json test/manual/mongodb_oidc.prose.test.ts", + "check:oidc": "mocha --config test/mocha_mongodb.json test/manual/mongodb_oidc.prose.test.ts", "check:ocsp": "mocha --config test/manual/mocharc.json test/manual/ocsp_support.test.js", "check:kerberos": "nyc mocha --config test/manual/mocharc.json test/manual/kerberos.test.ts", "check:tls": "mocha --config test/manual/mocharc.json test/manual/tls_support.test.js", diff --git a/src/cmap/auth/mongo_credentials.ts b/src/cmap/auth/mongo_credentials.ts index b24c395fcc..9239cc171b 100644 --- a/src/cmap/auth/mongo_credentials.ts +++ b/src/cmap/auth/mongo_credentials.ts @@ -30,6 +30,18 @@ function getDefaultAuthMechanism(hello?: Document): AuthMechanism { return AuthMechanism.MONGODB_CR; } +const ALLOWED_HOSTS_ERROR = 'Auth mechanism property ALLOWED_HOSTS must be an array of strings.'; + +/** @internal */ +export const DEFAULT_ALLOWED_HOSTS = [ + '*.mongodb.net', + '*.mongodb-dev.net', + '*.mongodbgov.net', + 'localhost', + '127.0.0.1', + '::1' +]; + /** @public */ export interface AuthMechanismProperties extends Document { SERVICE_HOST?: string; @@ -43,11 +55,13 @@ export interface AuthMechanismProperties extends Document { REFRESH_TOKEN_CALLBACK?: OIDCRefreshFunction; /** @experimental */ PROVIDER_NAME?: 'aws'; + /** @experimental */ + ALLOWED_HOSTS?: string[]; } /** @public */ export interface MongoCredentialsOptions { - username: string; + username?: string; password: string; source: string; db?: string; @@ -72,7 +86,7 @@ export class MongoCredentials { readonly mechanismProperties: AuthMechanismProperties; constructor(options: MongoCredentialsOptions) { - this.username = options.username; + this.username = options.username ?? ''; this.password = options.password; this.source = options.source; if (!this.source && options.db) { @@ -101,6 +115,13 @@ export class MongoCredentials { } } + if (this.mechanism === AuthMechanism.MONGODB_OIDC && !this.mechanismProperties.ALLOWED_HOSTS) { + this.mechanismProperties = { + ...this.mechanismProperties, + ALLOWED_HOSTS: DEFAULT_ALLOWED_HOSTS + }; + } + Object.freeze(this.mechanismProperties); Object.freeze(this); } @@ -181,6 +202,18 @@ export class MongoCredentials { `Either a PROVIDER_NAME or a REQUEST_TOKEN_CALLBACK must be specified for mechanism '${this.mechanism}'.` ); } + + if (this.mechanismProperties.ALLOWED_HOSTS) { + const hosts = this.mechanismProperties.ALLOWED_HOSTS; + if (!Array.isArray(hosts)) { + throw new MongoInvalidArgumentError(ALLOWED_HOSTS_ERROR); + } + for (const host of hosts) { + if (typeof host !== 'string') { + throw new MongoInvalidArgumentError(ALLOWED_HOSTS_ERROR); + } + } + } } if (AUTH_MECHS_AUTH_SRC_EXTERNAL.has(this.mechanism)) { diff --git a/src/cmap/auth/mongodb_oidc.ts b/src/cmap/auth/mongodb_oidc.ts index 69ae3e0d3b..d62591f2bf 100644 --- a/src/cmap/auth/mongodb_oidc.ts +++ b/src/cmap/auth/mongodb_oidc.ts @@ -1,21 +1,23 @@ +import type { Document } from 'bson'; + import { MongoInvalidArgumentError, MongoMissingCredentialsError } from '../../error'; import type { HandshakeDocument } from '../connect'; -import { type AuthContext, AuthProvider } from './auth_provider'; +import type { Connection } from '../connection'; +import { AuthContext, AuthProvider } from './auth_provider'; import type { MongoCredentials } from './mongo_credentials'; import { AwsServiceWorkflow } from './mongodb_oidc/aws_service_workflow'; import { CallbackWorkflow } from './mongodb_oidc/callback_workflow'; -import type { Workflow } from './mongodb_oidc/workflow'; + +/** Error when credentials are missing. */ +const MISSING_CREDENTIALS_ERROR = 'AuthContext must provide credentials.'; /** * @public * @experimental */ -export interface OIDCMechanismServerStep1 { - authorizationEndpoint?: string; - tokenEndpoint?: string; - deviceAuthorizationEndpoint?: string; +export interface IdPServerInfo { + issuer: string; clientId: string; - clientSecret?: string; requestScopes?: string[]; } @@ -23,35 +25,61 @@ export interface OIDCMechanismServerStep1 { * @public * @experimental */ -export interface OIDCRequestTokenResult { +export interface IdPServerResponse { accessToken: string; expiresInSeconds?: number; refreshToken?: string; } +/** + * @public + * @experimental + */ +export interface OIDCCallbackContext { + refreshToken?: string; + timeoutSeconds?: number; + timeoutContext?: AbortSignal; + version: number; +} + /** * @public * @experimental */ export type OIDCRequestFunction = ( - principalName: string, - serverResult: OIDCMechanismServerStep1, - timeout: AbortSignal | number -) => Promise; + info: IdPServerInfo, + context: OIDCCallbackContext +) => Promise; /** * @public * @experimental */ export type OIDCRefreshFunction = ( - principalName: string, - serverResult: OIDCMechanismServerStep1, - result: OIDCRequestTokenResult, - timeout: AbortSignal | number -) => Promise; + info: IdPServerInfo, + context: OIDCCallbackContext +) => Promise; type ProviderName = 'aws' | 'callback'; +export interface Workflow { + /** + * All device workflows must implement this method in order to get the access + * token and then call authenticate with it. + */ + execute( + connection: Connection, + credentials: MongoCredentials, + reauthenticating: boolean, + response?: Document + ): Promise; + + /** + * Get the document to add for speculative authentication. + */ + speculativeAuth(credentials: MongoCredentials): Promise; +} + /** @internal */ export const OIDC_WORKFLOWS: Map = new Map(); OIDC_WORKFLOWS.set('callback', new CallbackWorkflow()); @@ -73,19 +101,10 @@ export class MongoDBOIDC extends AuthProvider { * Authenticate using OIDC */ override async auth(authContext: AuthContext): Promise { - const { connection, credentials, response, reauthenticating } = authContext; - - if (response?.speculativeAuthenticate) { - return; - } - - if (!credentials) { - throw new MongoMissingCredentialsError('AuthContext must provide credentials.'); - } - + const { connection, reauthenticating, response } = authContext; + const credentials = getCredentials(authContext); const workflow = getWorkflow(credentials); - - await workflow.execute(connection, credentials, reauthenticating); + await workflow.execute(connection, credentials, reauthenticating, response); } /** @@ -95,19 +114,24 @@ export class MongoDBOIDC extends AuthProvider { handshakeDoc: HandshakeDocument, authContext: AuthContext ): Promise { - const { credentials } = authContext; - - if (!credentials) { - throw new MongoMissingCredentialsError('AuthContext must provide credentials.'); - } - + const credentials = getCredentials(authContext); const workflow = getWorkflow(credentials); - - const result = await workflow.speculativeAuth(); + const result = await workflow.speculativeAuth(credentials); return { ...handshakeDoc, ...result }; } } +/** + * Get credentials from the auth context, throwing if they do not exist. + */ +function getCredentials(authContext: AuthContext): MongoCredentials { + const { credentials } = authContext; + if (!credentials) { + throw new MongoMissingCredentialsError(MISSING_CREDENTIALS_ERROR); + } + return credentials; +} + /** * Gets either a device workflow or callback workflow. */ diff --git a/src/cmap/auth/mongodb_oidc/aws_service_workflow.ts b/src/cmap/auth/mongodb_oidc/aws_service_workflow.ts index a6563cc520..5dd07b1d28 100644 --- a/src/cmap/auth/mongodb_oidc/aws_service_workflow.ts +++ b/src/cmap/auth/mongodb_oidc/aws_service_workflow.ts @@ -1,8 +1,11 @@ -import { readFile } from 'fs/promises'; +import * as fs from 'fs'; import { MongoAWSError } from '../../../error'; import { ServiceWorkflow } from './service_workflow'; +/** Error for when the token is missing in the environment. */ +const TOKEN_MISSING_ERROR = 'AWS_WEB_IDENTITY_TOKEN_FILE must be set in the environment.'; + /** * Device workflow implementation for AWS. * @@ -19,8 +22,8 @@ export class AwsServiceWorkflow extends ServiceWorkflow { async getToken(): Promise { const tokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE; if (!tokenFile) { - throw new MongoAWSError('AWS_WEB_IDENTITY_TOKEN_FILE must be set in the environment.'); + throw new MongoAWSError(TOKEN_MISSING_ERROR); } - return readFile(tokenFile, 'utf8'); + return fs.promises.readFile(tokenFile, 'utf8'); } } diff --git a/src/cmap/auth/mongodb_oidc/cache.ts b/src/cmap/auth/mongodb_oidc/cache.ts new file mode 100644 index 0000000000..4a0a825bd4 --- /dev/null +++ b/src/cmap/auth/mongodb_oidc/cache.ts @@ -0,0 +1,27 @@ +/** + * Base class for OIDC caches. + */ +export abstract class Cache { + entries: Map; + + /** + * Create a new cache. + */ + constructor() { + this.entries = new Map(); + } + + /** + * Clear the cache. + */ + clear() { + this.entries.clear(); + } + + /** + * Create a cache key from the address and username. + */ + cacheKey(address: string, username: string, callbackHash: string): string { + return JSON.stringify([address, username, callbackHash]); + } +} diff --git a/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts b/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts new file mode 100644 index 0000000000..9e77b0614c --- /dev/null +++ b/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts @@ -0,0 +1,107 @@ +import { MongoInvalidArgumentError } from '../../../error'; +import type { Connection } from '../../connection'; +import type { MongoCredentials } from '../mongo_credentials'; +import type { + IdPServerInfo, + IdPServerResponse, + OIDCCallbackContext, + OIDCRefreshFunction, + OIDCRequestFunction +} from '../mongodb_oidc'; +import { Cache } from './cache'; + +/** Error message for when request callback is missing. */ +const REQUEST_CALLBACK_REQUIRED_ERROR = + 'Auth mechanism property REQUEST_TOKEN_CALLBACK is required.'; +/* Counter for function "hashes".*/ +let FN_HASH_COUNTER = 0; +/* No function present function */ +const NO_FUNCTION: OIDCRequestFunction = async () => ({ accessToken: 'test' }); +/* The map of function hashes */ +const FN_HASHES = new WeakMap(); +/* Put the no function hash in the map. */ +FN_HASHES.set(NO_FUNCTION, FN_HASH_COUNTER); + +/** + * An entry of callbacks in the cache. + */ +interface CallbacksEntry { + requestCallback: OIDCRequestFunction; + refreshCallback?: OIDCRefreshFunction; + callbackHash: string; +} + +/** + * A cache of request and refresh callbacks per server/user. + */ +export class CallbackLockCache extends Cache { + /** + * Get the callbacks for the connection and credentials. If an entry does not + * exist a new one will get set. + */ + getCallbacks(connection: Connection, credentials: MongoCredentials): CallbacksEntry { + const requestCallback = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK; + const refreshCallback = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK; + if (!requestCallback) { + throw new MongoInvalidArgumentError(REQUEST_CALLBACK_REQUIRED_ERROR); + } + const callbackHash = hashFunctions(requestCallback, refreshCallback); + const key = this.cacheKey(connection.address, credentials.username, callbackHash); + const entry = this.entries.get(key); + if (entry) { + return entry; + } + return this.setCallbacks(key, callbackHash, requestCallback, refreshCallback); + } + + /** + * Set locked callbacks on for connection and credentials. + */ + private setCallbacks( + key: string, + callbackHash: string, + requestCallback: OIDCRequestFunction, + refreshCallback?: OIDCRefreshFunction + ): CallbacksEntry { + const entry = { + requestCallback: withLock(requestCallback), + refreshCallback: refreshCallback ? withLock(refreshCallback) : undefined, + callbackHash: callbackHash + }; + this.entries.set(key, entry); + return entry; + } +} + +/** + * Ensure the callback is only executed one at a time. + */ +function withLock(callback: OIDCRequestFunction | OIDCRefreshFunction) { + let lock: Promise = Promise.resolve(); + return async (info: IdPServerInfo, context: OIDCCallbackContext): Promise => { + await lock; + lock = lock.then(() => callback(info, context)); + return lock; + }; +} + +/** + * Get the hash string for the request and refresh functions. + */ +function hashFunctions(requestFn: OIDCRequestFunction, refreshFn?: OIDCRefreshFunction): string { + let requestHash = FN_HASHES.get(requestFn); + let refreshHash = FN_HASHES.get(refreshFn ?? NO_FUNCTION); + if (requestHash == null) { + // Create a new one for the function and put it in the map. + FN_HASH_COUNTER++; + requestHash = FN_HASH_COUNTER; + FN_HASHES.set(requestFn, FN_HASH_COUNTER); + } + if (refreshHash == null && refreshFn) { + // Create a new one for the function and put it in the map. + FN_HASH_COUNTER++; + refreshHash = FN_HASH_COUNTER; + FN_HASHES.set(refreshFn, FN_HASH_COUNTER); + } + return `${requestHash}-${refreshHash}`; +} diff --git a/src/cmap/auth/mongodb_oidc/callback_workflow.ts b/src/cmap/auth/mongodb_oidc/callback_workflow.ts index ffebe7e49b..3ef1251fc4 100644 --- a/src/cmap/auth/mongodb_oidc/callback_workflow.ts +++ b/src/cmap/auth/mongodb_oidc/callback_workflow.ts @@ -1,16 +1,33 @@ import { Binary, BSON, type Document } from 'bson'; -import { MongoInvalidArgumentError, MongoMissingCredentialsError } from '../../../error'; +import { MONGODB_ERROR_CODES, MongoError, MongoMissingCredentialsError } from '../../../error'; import { ns } from '../../../utils'; import type { Connection } from '../../connection'; import type { MongoCredentials } from '../mongo_credentials'; -import type { OIDCMechanismServerStep1, OIDCRequestTokenResult } from '../mongodb_oidc'; +import type { + IdPServerInfo, + IdPServerResponse, + OIDCCallbackContext, + OIDCRefreshFunction, + OIDCRequestFunction, + Workflow +} from '../mongodb_oidc'; import { AuthMechanism } from '../providers'; +import { CallbackLockCache } from './callback_lock_cache'; import { TokenEntryCache } from './token_entry_cache'; -import type { Workflow } from './workflow'; -/* 5 minutes in milliseconds */ -const TIMEOUT_MS = 300000; +/** The current version of OIDC implementation. */ +const OIDC_VERSION = 0; + +/** 5 minutes in seconds */ +const TIMEOUT_S = 300; + +/** Properties allowed on results of callbacks. */ +const RESULT_PROPERTIES = ['accessToken', 'expiresInSeconds', 'refreshToken']; + +/** Error message when the callback result is invalid. */ +const CALLBACK_RESULT_ERROR = + 'User provided OIDC callbacks must return a valid object with an accessToken.'; /** * OIDC implementation of a callback based workflow. @@ -18,206 +35,248 @@ const TIMEOUT_MS = 300000; */ export class CallbackWorkflow implements Workflow { cache: TokenEntryCache; + callbackCache: CallbackLockCache; /** * Instantiate the workflow */ constructor() { this.cache = new TokenEntryCache(); + this.callbackCache = new CallbackLockCache(); } /** - * Get the document to add for speculative authentication. Is empty when - * callbacks are in play. + * Get the document to add for speculative authentication. This also needs + * to add a db field from the credentials source. */ - speculativeAuth(): Promise { - return Promise.resolve({}); + async speculativeAuth(credentials: MongoCredentials): Promise { + const document = startCommandDocument(credentials); + document.db = credentials.source; + return { speculativeAuthenticate: document }; } /** - * Execute the workflow. - * - * Steps: - * - If an entry is in the cache - * - If it is not expired - * - Skip step one and use the entry to execute step two. - * - If it is expired - * - If the refresh callback exists - * - remove expired entry from cache - * - call the refresh callback. - * - put the new entry in the cache. - * - execute step two. - * - If the refresh callback does not exist. - * - remove expired entry from cache - * - call the request callback. - * - put the new entry in the cache. - * - execute step two. - * - If no entry is in the cache. - * - execute step one. - * - call the refresh callback. - * - put the new entry in the cache. - * - execute step two. + * Execute the OIDC callback workflow. */ async execute( connection: Connection, credentials: MongoCredentials, - reauthenticate = false + reauthenticating: boolean, + response?: Document ): Promise { - const request = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK; - const refresh = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK; - - const entry = this.cache.getEntry( - connection.address, - credentials.username, - request || null, - refresh || null + // Get the callbacks with locks from the callback lock cache. + const { requestCallback, refreshCallback, callbackHash } = this.callbackCache.getCallbacks( + connection, + credentials ); + // Look for an existing entry in the cache. + const entry = this.cache.getEntry(connection.address, credentials.username, callbackHash); + let result; if (entry) { - // Check if the entry is not expired and if we are reauthenticating. - if (!reauthenticate && entry.isValid()) { - // Skip step one and execute the step two saslContinue. - try { - const result = await finishAuth(entry.tokenResult, undefined, connection, credentials); - return result; - } catch (error) { - // If authentication errors when using a cached token we remove it from - // the cache. - this.cache.deleteEntry( - connection.address, - credentials.username || '', - request || null, - refresh || null - ); - throw error; - } - } else { - // Remove the expired entry from the cache. - this.cache.deleteEntry( - connection.address, - credentials.username || '', - request || null, - refresh || null + // Reauthentication cannot use a token from the cache since the server has + // stated it is invalid by the request for reauthentication. + if (entry.isValid() && !reauthenticating) { + // Presence of a valid cache entry means we can skip to the finishing step. + result = await this.finishAuthentication( + connection, + credentials, + entry.tokenResult, + response?.speculativeAuthenticate?.conversationId ); - // Execute a refresh of the token and finish auth. - return this.refreshAndFinish( + } else { + // Presence of an expired cache entry means we must fetch a new one and + // then execute the final step. + const tokenResult = await this.fetchAccessToken( connection, credentials, - entry.serverResult, - entry.tokenResult + entry.serverInfo, + reauthenticating, + callbackHash, + requestCallback, + refreshCallback ); + try { + result = await this.finishAuthentication( + connection, + credentials, + tokenResult, + reauthenticating ? undefined : response?.speculativeAuthenticate?.conversationId + ); + } catch (error) { + // If we are reauthenticating and this errors with reauthentication + // required, we need to do the entire process over again and clear + // the cache entry. + if ( + reauthenticating && + error instanceof MongoError && + error.code === MONGODB_ERROR_CODES.Reauthenticate + ) { + this.cache.deleteEntry(connection.address, credentials.username, callbackHash); + result = await this.execute(connection, credentials, reauthenticating); + } else { + throw error; + } + } } } else { - // No entry means to start with the step one saslStart. - const result = await connection.commandAsync( - ns(credentials.source), - startCommandDocument(credentials), - undefined + // No entry in the cache requires us to do all authentication steps + // from start to finish, including getting a fresh token for the cache. + const startDocument = await this.startAuthentication( + connection, + credentials, + reauthenticating, + response + ); + const conversationId = startDocument.conversationId; + const serverResult = BSON.deserialize(startDocument.payload.buffer) as IdPServerInfo; + const tokenResult = await this.fetchAccessToken( + connection, + credentials, + serverResult, + reauthenticating, + callbackHash, + requestCallback, + refreshCallback + ); + result = await this.finishAuthentication( + connection, + credentials, + tokenResult, + conversationId ); - const stepOne = BSON.deserialize(result.payload.buffer) as OIDCMechanismServerStep1; - // Call the request callback and finish auth. - return this.requestAndFinish(connection, credentials, stepOne, result.conversationId); } + return result; } /** - * Execute the refresh callback if it exists, otherwise the request callback, then - * finish the authentication. + * Starts the callback authentication process. If there is a speculative + * authentication document from the initial handshake, then we will use that + * value to get the issuer, otherwise we will send the saslStart command. */ - private async refreshAndFinish( + private async startAuthentication( connection: Connection, credentials: MongoCredentials, - stepOneResult: OIDCMechanismServerStep1, - tokenResult: OIDCRequestTokenResult, - conversationId?: number + reauthenticating: boolean, + response?: Document ): Promise { - const request = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK; - const refresh = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK; - // If a refresh callback exists, use it. Otherwise use the request callback. - if (refresh) { - const result: OIDCRequestTokenResult = await refresh( - credentials.username, - stepOneResult, - tokenResult, - TIMEOUT_MS - ); - // Validate the result. - if (!result || !result.accessToken) { - throw new MongoMissingCredentialsError( - 'REFRESH_TOKEN_CALLBACK must return a valid object with an accessToken' - ); - } - // Cache a new entry and continue with the saslContinue. - this.cache.addEntry( - connection.address, - credentials.username || '', - request || null, - refresh, - result, - stepOneResult - ); - return finishAuth(result, conversationId, connection, credentials); + let result; + if (!reauthenticating && response?.speculativeAuthenticate) { + result = response.speculativeAuthenticate; } else { - // Fallback to using the request callback. - return this.requestAndFinish(connection, credentials, stepOneResult, conversationId); + result = await connection.commandAsync( + ns(credentials.source), + startCommandDocument(credentials), + undefined + ); } + return result; } /** - * Execute the request callback and finish authentication. + * Finishes the callback authentication process. */ - private async requestAndFinish( + private async finishAuthentication( connection: Connection, credentials: MongoCredentials, - stepOneResult: OIDCMechanismServerStep1, + tokenResult: IdPServerResponse, conversationId?: number ): Promise { - // Call the request callback. - const request = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK; - const refresh = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK; - // Always clear expired entries from the cache on each finish as cleanup. - this.cache.deleteExpiredEntries(); - if (!request) { - // Request callback must be present. - throw new MongoInvalidArgumentError( - 'Auth mechanism property REQUEST_TOKEN_CALLBACK is required.' - ); + const result = await connection.commandAsync( + ns(credentials.source), + finishCommandDocument(tokenResult.accessToken, conversationId), + undefined + ); + return result; + } + + /** + * Fetches an access token using either the request or refresh callbacks and + * puts it in the cache. + */ + private async fetchAccessToken( + connection: Connection, + credentials: MongoCredentials, + serverInfo: IdPServerInfo, + reauthenticating: boolean, + callbackHash: string, + requestCallback: OIDCRequestFunction, + refreshCallback?: OIDCRefreshFunction + ): Promise { + // Get the token from the cache. + const entry = this.cache.getEntry(connection.address, credentials.username, callbackHash); + let result; + const context: OIDCCallbackContext = { timeoutSeconds: TIMEOUT_S, version: OIDC_VERSION }; + // Check if there's a token in the cache. + if (entry) { + // If the cache entry is valid, return the token result. + if (entry.isValid() && !reauthenticating) { + return entry.tokenResult; + } + // If the cache entry is not valid, remove it from the cache and first attempt + // to use the refresh callback to get a new token. If no refresh callback + // exists, then fallback to the request callback. + if (refreshCallback) { + context.refreshToken = entry.tokenResult.refreshToken; + result = await refreshCallback(serverInfo, context); + } else { + result = await requestCallback(serverInfo, context); + } + } else { + // With no token in the cache we use the request callback. + result = await requestCallback(serverInfo, context); } - const tokenResult = await request(credentials.username, stepOneResult, TIMEOUT_MS); - // Validate the result. - if (!tokenResult || !tokenResult.accessToken) { - throw new MongoMissingCredentialsError( - 'REQUEST_TOKEN_CALLBACK must return a valid object with an accessToken' - ); + // Validate that the result returned by the callback is acceptable. If it is not + // we must clear the token result from the cache. + if (isCallbackResultInvalid(result)) { + this.cache.deleteEntry(connection.address, credentials.username, callbackHash); + throw new MongoMissingCredentialsError(CALLBACK_RESULT_ERROR); } - // Cache a new entry and continue with the saslContinue. + // Cleanup the cache. + this.cache.deleteExpiredEntries(); + // Put the new entry into the cache. this.cache.addEntry( connection.address, credentials.username || '', - request, - refresh || null, - tokenResult, - stepOneResult + callbackHash, + result, + serverInfo ); - return finishAuth(tokenResult, conversationId, connection, credentials); + return result; } } /** - * Cache the result of the user supplied callback and execute the - * step two saslContinue. + * Generate the finishing command document for authentication. Will be a + * saslStart or saslContinue depending on the presence of a conversation id. */ -async function finishAuth( - result: OIDCRequestTokenResult, - conversationId: number | undefined, - connection: Connection, - credentials: MongoCredentials -): Promise { - // Execute the step two saslContinue. - return connection.commandAsync( - ns(credentials.source), - continueCommandDocument(result.accessToken, conversationId), - undefined - ); +function finishCommandDocument(token: string, conversationId?: number): Document { + if (conversationId != null && typeof conversationId === 'number') { + return { + saslContinue: 1, + conversationId: conversationId, + payload: new Binary(BSON.serialize({ jwt: token })) + }; + } + // saslContinue requires a conversationId in the command to be valid so in this + // case the server allows "step two" to actually be a saslStart with the token + // as the jwt since the use of the cached value has no correlating conversating + // on the particular connection. + return { + saslStart: 1, + mechanism: AuthMechanism.MONGODB_OIDC, + payload: new Binary(BSON.serialize({ jwt: token })) + }; +} + +/** + * Determines if a result returned from a request or refresh callback + * function is invalid. This means the result is nullish, doesn't contain + * the accessToken required field, and does not contain extra fields. + */ +function isCallbackResultInvalid(tokenResult: unknown): boolean { + if (tokenResult == null || typeof tokenResult !== 'object') return true; + if (!('accessToken' in tokenResult)) return true; + return !Object.getOwnPropertyNames(tokenResult).every(prop => RESULT_PROPERTIES.includes(prop)); } /** @@ -235,25 +294,3 @@ function startCommandDocument(credentials: MongoCredentials): Document { payload: new Binary(BSON.serialize(payload)) }; } - -/** - * Generate the saslContinue command document. - */ -function continueCommandDocument(token: string, conversationId?: number): Document { - if (conversationId) { - return { - saslContinue: 1, - conversationId: conversationId, - payload: new Binary(BSON.serialize({ jwt: token })) - }; - } - // saslContinue requires a conversationId in the command to be valid so in this - // case the server allows "step two" to actually be a saslStart with the token - // as the jwt since the use of the cached value has no correlating conversating - // on the particular connection. - return { - saslStart: 1, - mechanism: AuthMechanism.MONGODB_OIDC, - payload: new Binary(BSON.serialize({ jwt: token })) - }; -} diff --git a/src/cmap/auth/mongodb_oidc/service_workflow.ts b/src/cmap/auth/mongodb_oidc/service_workflow.ts index 85a43294b1..4c3e5bb316 100644 --- a/src/cmap/auth/mongodb_oidc/service_workflow.ts +++ b/src/cmap/auth/mongodb_oidc/service_workflow.ts @@ -3,8 +3,8 @@ import { BSON, type Document } from 'bson'; import { ns } from '../../../utils'; import type { Connection } from '../../connection'; import type { MongoCredentials } from '../mongo_credentials'; +import type { Workflow } from '../mongodb_oidc'; import { AuthMechanism } from '../providers'; -import type { Workflow } from './workflow'; /** * Common behaviour for OIDC device workflows. @@ -24,9 +24,11 @@ export abstract class ServiceWorkflow implements Workflow { /** * Get the document to add for speculative authentication. */ - async speculativeAuth(): Promise { + async speculativeAuth(credentials: MongoCredentials): Promise { const token = await this.getToken(); - return { speculativeAuthenticate: commandDocument(token) }; + const document = commandDocument(token); + document.db = credentials.source; + return { speculativeAuthenticate: document }; } /** diff --git a/src/cmap/auth/mongodb_oidc/token_entry_cache.ts b/src/cmap/auth/mongodb_oidc/token_entry_cache.ts index ec6ed3eb3a..0c24838a5d 100644 --- a/src/cmap/auth/mongodb_oidc/token_entry_cache.ts +++ b/src/cmap/auth/mongodb_oidc/token_entry_cache.ts @@ -1,41 +1,22 @@ -import type { - OIDCMechanismServerStep1, - OIDCRefreshFunction, - OIDCRequestFunction, - OIDCRequestTokenResult -} from '../mongodb_oidc'; +import type { IdPServerInfo, IdPServerResponse } from '../mongodb_oidc'; +import { Cache } from './cache'; -/* 5 minutes in milliseonds */ +/* 5 minutes in milliseconds */ const EXPIRATION_BUFFER_MS = 300000; /* Default expiration is now for when no expiration provided */ const DEFAULT_EXPIRATION_SECS = 0; -/* Counter for function "hashes".*/ -let FN_HASH_COUNTER = 0; -/* No function present function */ -const NO_FUNCTION: OIDCRequestFunction = () => { - return Promise.resolve({ accessToken: 'test' }); -}; -/* The map of function hashes */ -const FN_HASHES = new WeakMap(); -/* Put the no function hash in the map. */ -FN_HASHES.set(NO_FUNCTION, FN_HASH_COUNTER); - /** @internal */ export class TokenEntry { - tokenResult: OIDCRequestTokenResult; - serverResult: OIDCMechanismServerStep1; + tokenResult: IdPServerResponse; + serverInfo: IdPServerInfo; expiration: number; /** * Instantiate the entry. */ - constructor( - tokenResult: OIDCRequestTokenResult, - serverResult: OIDCMechanismServerStep1, - expiration: number - ) { + constructor(tokenResult: IdPServerResponse, serverInfo: IdPServerInfo, expiration: number) { this.tokenResult = tokenResult; - this.serverResult = serverResult; + this.serverInfo = serverInfo; this.expiration = expiration; } @@ -52,62 +33,38 @@ export class TokenEntry { * Cache of OIDC token entries. * @internal */ -export class TokenEntryCache { - entries: Map; - - constructor() { - this.entries = new Map(); - } - +export class TokenEntryCache extends Cache { /** * Set an entry in the token cache. */ addEntry( address: string, username: string, - requestFn: OIDCRequestFunction | null, - refreshFn: OIDCRefreshFunction | null, - tokenResult: OIDCRequestTokenResult, - serverResult: OIDCMechanismServerStep1 + callbackHash: string, + tokenResult: IdPServerResponse, + serverInfo: IdPServerInfo ): TokenEntry { const entry = new TokenEntry( tokenResult, - serverResult, + serverInfo, expirationTime(tokenResult.expiresInSeconds) ); - this.entries.set(cacheKey(address, username, requestFn, refreshFn), entry); + this.entries.set(this.cacheKey(address, username, callbackHash), entry); return entry; } - /** - * Clear the cache. - */ - clear(): void { - this.entries.clear(); - } - /** * Delete an entry from the cache. */ - deleteEntry( - address: string, - username: string, - requestFn: OIDCRequestFunction | null, - refreshFn: OIDCRefreshFunction | null - ): void { - this.entries.delete(cacheKey(address, username, requestFn, refreshFn)); + deleteEntry(address: string, username: string, callbackHash: string): void { + this.entries.delete(this.cacheKey(address, username, callbackHash)); } /** * Get an entry from the cache. */ - getEntry( - address: string, - username: string, - requestFn: OIDCRequestFunction | null, - refreshFn: OIDCRefreshFunction | null - ): TokenEntry | undefined { - return this.entries.get(cacheKey(address, username, requestFn, refreshFn)); + getEntry(address: string, username: string, callbackHash: string): TokenEntry | undefined { + return this.entries.get(this.cacheKey(address, username, callbackHash)); } /** @@ -128,39 +85,3 @@ export class TokenEntryCache { function expirationTime(expiresInSeconds?: number): number { return Date.now() + (expiresInSeconds ?? DEFAULT_EXPIRATION_SECS) * 1000; } - -/** - * Create a cache key from the address and username. - */ -function cacheKey( - address: string, - username: string, - requestFn: OIDCRequestFunction | null, - refreshFn: OIDCRefreshFunction | null -): string { - return `${address}-${username}-${hashFunctions(requestFn, refreshFn)}`; -} - -/** - * Get the hash string for the request and refresh functions. - */ -function hashFunctions( - requestFn: OIDCRequestFunction | null, - refreshFn: OIDCRefreshFunction | null -): string { - let requestHash = FN_HASHES.get(requestFn || NO_FUNCTION); - let refreshHash = FN_HASHES.get(refreshFn || NO_FUNCTION); - if (!requestHash && requestFn) { - // Create a new one for the function and put it in the map. - FN_HASH_COUNTER++; - requestHash = FN_HASH_COUNTER; - FN_HASHES.set(requestFn, FN_HASH_COUNTER); - } - if (!refreshHash && refreshFn) { - // Create a new one for the function and put it in the map. - FN_HASH_COUNTER++; - refreshHash = FN_HASH_COUNTER; - FN_HASHES.set(refreshFn, FN_HASH_COUNTER); - } - return `${requestHash}-${refreshHash}`; -} diff --git a/src/cmap/auth/mongodb_oidc/workflow.ts b/src/cmap/auth/mongodb_oidc/workflow.ts deleted file mode 100644 index 68d0b97688..0000000000 --- a/src/cmap/auth/mongodb_oidc/workflow.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Document } from 'bson'; - -import type { Connection } from '../../connection'; -import type { MongoCredentials } from '../mongo_credentials'; - -export interface Workflow { - /** - * All device workflows must implement this method in order to get the access - * token and then call authenticate with it. - */ - execute( - connection: Connection, - credentials: MongoCredentials, - reauthenticate?: boolean - ): Promise; - - /** - * Get the document to add for speculative authentication. - */ - speculativeAuth(): Promise; -} diff --git a/src/connection_string.ts b/src/connection_string.ts index 9c6348c798..ac2f2d25f6 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -310,6 +310,17 @@ export function parseOptions( ); } + const uriMechanismProperties = urlOptions.get('authMechanismProperties'); + if (uriMechanismProperties) { + for (const property of uriMechanismProperties) { + if (/(^|,)ALLOWED_HOSTS:/.test(property as string)) { + throw new MongoParseError( + 'Auth mechanism property ALLOWED_HOSTS is not allowed in the connection string.' + ); + } + } + } + if (objectOptions.has('loadBalanced')) { throw new MongoParseError('loadBalanced is only a valid option in the URI'); } diff --git a/src/index.ts b/src/index.ts index a0d67a322c..e3b1950cfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,10 +204,11 @@ export type { MongoCredentialsOptions } from './cmap/auth/mongo_credentials'; export type { - OIDCMechanismServerStep1, + IdPServerInfo, + IdPServerResponse, + OIDCCallbackContext, OIDCRefreshFunction, - OIDCRequestFunction, - OIDCRequestTokenResult + OIDCRequestFunction } from './cmap/auth/mongodb_oidc'; export type { BinMsg, diff --git a/src/mongo_client.ts b/src/mongo_client.ts index d7779aeb47..fef1da0758 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -4,8 +4,12 @@ import { promisify } from 'util'; import { BSONSerializeOptions, Document, resolveBSONOptions } from './bson'; import { ChangeStream, ChangeStreamDocument, ChangeStreamOptions } from './change_stream'; -import type { AuthMechanismProperties, MongoCredentials } from './cmap/auth/mongo_credentials'; -import type { AuthMechanism } from './cmap/auth/providers'; +import { + AuthMechanismProperties, + DEFAULT_ALLOWED_HOSTS, + MongoCredentials +} from './cmap/auth/mongo_credentials'; +import { AuthMechanism } from './cmap/auth/providers'; import type { LEGAL_TCP_SOCKET_OPTIONS, LEGAL_TLS_SOCKET_OPTIONS } from './cmap/connect'; import type { Connection } from './cmap/connection'; import type { ClientMetadata } from './cmap/handshake/client_metadata'; @@ -25,7 +29,7 @@ import { readPreferenceServerSelector } from './sdam/server_selection'; import type { SrvPoller } from './sdam/srv_polling'; import { Topology, TopologyEvents } from './sdam/topology'; import { ClientSession, ClientSessionOptions, ServerSessionPool } from './sessions'; -import { HostAddress, MongoDBNamespace, ns, resolveOptions } from './utils'; +import { HostAddress, hostMatchesWildcards, MongoDBNamespace, ns, resolveOptions } from './utils'; import type { W, WriteConcern, WriteConcernSettings } from './write_concern'; /** @public */ @@ -448,6 +452,25 @@ export class MongoClient extends TypedEventEmitter { } } + // It is important to perform validation of hosts AFTER SRV resolution, to check the real hostname, + // but BEFORE we even attempt connecting with a potentially not allowed hostname + if (options.credentials?.mechanism === AuthMechanism.MONGODB_OIDC) { + const allowedHosts = + options.credentials?.mechanismProperties?.ALLOWED_HOSTS || DEFAULT_ALLOWED_HOSTS; + const isServiceAuth = !!options.credentials?.mechanismProperties?.PROVIDER_NAME; + if (!isServiceAuth) { + for (const host of options.hosts) { + if (!hostMatchesWildcards(host.toHostPort().host, allowedHosts)) { + throw new MongoInvalidArgumentError( + `Host '${host}' is not valid for OIDC authentication with ALLOWED_HOSTS of '${allowedHosts.join( + ',' + )}'` + ); + } + } + } + } + const topology = new Topology(options.hosts, options); // Events can be emitted before initialization is complete so we have to // save the reference to the topology on the client ASAP if the event handlers need to access it diff --git a/src/utils.ts b/src/utils.ts index 95bf757af2..23debd74e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -58,6 +58,23 @@ export const ByteUtils = { } }; +/** + * Determines if a connection's address matches a user provided list + * of domain wildcards. + */ +export function hostMatchesWildcards(host: string, wildcards: string[]): boolean { + for (const wildcard of wildcards) { + if ( + host === wildcard || + (wildcard.startsWith('*.') && host?.endsWith(wildcard.substring(2, wildcard.length))) || + (wildcard.startsWith('*/') && host?.endsWith(wildcard.substring(2, wildcard.length))) + ) { + return true; + } + } + return false; +} + /** * Throws if collectionName is not a valid mongodb collection namespace. * @internal diff --git a/test/manual/mongodb_oidc.prose.test.ts b/test/manual/mongodb_oidc.prose.test.ts index ce5728e597..1dd3eaf0dd 100644 --- a/test/manual/mongodb_oidc.prose.test.ts +++ b/test/manual/mongodb_oidc.prose.test.ts @@ -1,8 +1,23 @@ import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; import { expect } from 'chai'; - -import { MongoClient, MongoInvalidArgumentError, OIDC_WORKFLOWS } from '../mongodb'; +import * as sinon from 'sinon'; + +import { + Collection, + CommandFailedEvent, + CommandStartedEvent, + CommandSucceededEvent, + IdPServerInfo, + MongoClient, + MongoInvalidArgumentError, + MongoMissingCredentialsError, + MongoServerError, + OIDC_WORKFLOWS, + OIDCCallbackContext +} from '../mongodb'; +import { sleep } from '../tools/utils'; describe('MONGODB-OIDC', function () { context('when running in the environment', function () { @@ -12,602 +27,1202 @@ describe('MONGODB-OIDC', function () { }); describe('OIDC Auth Spec Prose Tests', function () { - // Drivers MUST be able to authenticate using OIDC callback(s) when there - // is one principal configured. - describe('1. Callback-Driven Auth', function () { - // - Create a request callback that reads in the generated ``test_user1`` token file. - const requestCallback = async () => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { + // Set up the cache variable. + const cache = OIDC_WORKFLOWS.get('callback').cache; + const callbackCache = OIDC_WORKFLOWS.get('callback').callbackCache; + // Creates a request function for use in the test. + const createRequestCallback = ( + username = 'test_user1', + expiresInSeconds?: number, + extraFields?: any + ) => { + return async (info: IdPServerInfo, context: OIDCCallbackContext) => { + const token = await readFile(path.join(process.env.OIDC_TOKEN_DIR, username), { + encoding: 'utf8' + }); + // Do some basic property assertions. + expect(context).to.have.property('timeoutSeconds'); + expect(info).to.have.property('issuer'); + expect(info).to.have.property('clientId'); + return generateResult(token, expiresInSeconds, extraFields); + }; + }; + + // Creates a refresh function for use in the test. + const createRefreshCallback = ( + username = 'test_user1', + expiresInSeconds?: number, + extraFields?: any + ) => { + return async (info: IdPServerInfo, context: OIDCCallbackContext) => { + const token = await readFile(path.join(process.env.OIDC_TOKEN_DIR, username), { encoding: 'utf8' }); - return { accessToken: token }; + // Do some basic property assertions. + expect(context).to.have.property('timeoutSeconds'); + expect(info).to.have.property('issuer'); + expect(info).to.have.property('clientId'); + return generateResult(token, expiresInSeconds, extraFields); }; + }; + + // Generates the result the request or refresh callback returns. + const generateResult = (token: string, expiresInSeconds?: number, extraFields?: any) => { + const response: OIDCRequestTokenResult = { accessToken: token }; + if (expiresInSeconds) { + response.expiresInSeconds = expiresInSeconds; + } + if (extraFields) { + return { ...response, ...extraFields }; + } + return response; + }; + + beforeEach(function () { + callbackCache.clear(); + }); - context('when no username is provided', function () { - let client; - let collection; - // - Create a client with a url of the form ``mongodb://localhost/?authMechanism=MONGODB-OIDC`` - // and the OIDC request callback. + describe('1. Callback-Driven Auth', function () { + let client: MongoClient; + let collection: Collection; + + beforeEach(function () { + cache.clear(); + }); + + afterEach(async function () { + await client?.close(); + }); + + describe('1.1 Single Principal Implicit Username', function () { before(function () { client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback + REQUEST_TOKEN_CALLBACK: createRequestCallback() } }); collection = client.db('test').collection('test'); }); - after(async () => { - await client?.close(); - }); - - // - Perform a ``find`` operation. - // - Clear the cache. + // Clear the cache. + // Create a request callback returns a valid token. + // Create a client that uses the default OIDC url and the request callback. + // Perform a find operation. that succeeds. + // Close the client. it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + const result = await collection.findOne(); + expect(result).to.be.null; }); }); - context('when a username is provided', function () { - let client; - let collection; - // - Create a client with a url of the form - // ``mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC`` and the OIDC request callback. + describe('1.2 Single Principal Explicit Username', function () { before(function () { client = new MongoClient('mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC', { authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback + REQUEST_TOKEN_CALLBACK: createRequestCallback() } }); collection = client.db('test').collection('test'); }); - after(async () => { - await client?.close(); + // Clear the cache. + // Create a request callback that returns a valid token. + // Create a client with a url of the form mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC and the OIDC request callback. + // Perform a find operation that succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; }); + }); - // - Perform a ``find`` operation. - // - Clear the cache. + describe('1.3 Multiple Principal User 1', function () { + before(function () { + client = new MongoClient( + 'mongodb://test_user1@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred', + { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: createRequestCallback() + } + } + ); + collection = client.db('test').collection('test'); + }); + + // Clear the cache. + // Create a request callback that returns a valid token. + // Create a client with a url of the form mongodb://test_user1@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred and a valid OIDC request callback. + // Perform a find operation that succeeds. + // Close the client. it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + const result = await collection.findOne(); + expect(result).to.be.null; }); }); - }); - // Drivers MUST be able to authenticate using the "aws" device workflow simulating - // an EC2 instance with an enabled web identity token provider, generated by - // Drivers Evergreen Tools. - describe('2. AWS Device Auth', function () { - const testTokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE; - let client; - let collection; - after(() => { - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = testTokenFile; - client?.close(); - }); - - // - Create a client with the url parameters - // ``?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME=aws``. - before(function () { - // Set the ``AWS_WEB_IDENTITY_TOKEN_FILE`` environment variable to the location - // of the ``test_user1`` generated token file. - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = `${process.env.OIDC_TOKEN_DIR}/test_user1`; - client = new MongoClient( - 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws' - ); - collection = client.db('test').collection('test'); - }); - - // - Perform a find operation on the client. - it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + describe('1.4 Multiple Principal User 2', function () { + before(function () { + client = new MongoClient( + 'mongodb://test_user2@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred', + { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user2') + } + } + ); + collection = client.db('test').collection('test'); + }); + + // Clear the cache. + // Create a request callback that reads in the generated test_user2 token file. + // Create a client with a url of the form mongodb://test_user2@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred and a valid OIDC request callback. + // Perform a find operation that succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + }); }); - }); - // Drivers MUST be able to authenticate using either authentication or device - // type if there are multiple principals configured on the server. Note that - // ``directConnection=true`` and ``readPreference=secondaryPreferred`` are needed - // because the server is a secondary on a replica set, on port ``27018``. - describe('3. Multiple Principals', function () { - context('when authenticating with user 1', function () { - context('when using a callback', function () { - let client; - let collection; - // - Create a request callback that reads in the generated ``test_user1`` token file. - const requestCallback = async () => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { - encoding: 'utf8' - }); - return { accessToken: token }; - }; - // - Create a client with a url of the form - // ``mongodb://test_user1@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred`` - // and the OIDC request callback. + describe('1.5 Multiple Principal No User', function () { + before(function () { + client = new MongoClient( + 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred', + { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: createRequestCallback() + } + } + ); + collection = client.db('test').collection('test'); + }); + + // Clear the cache. + // Create a client with a url of the form mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred and a valid OIDC request callback. + // Assert that a find operation fails. + // Close the client. + it('fails authentication', async function () { + try { + await collection.findOne(); + expect.fail('Expected OIDC auth to fail with no user provided'); + } catch (e) { + expect(e).to.be.instanceOf(MongoServerError); + expect(e.message).to.include('Authentication failed'); + } + }); + }); + + describe('1.6 Allowed Hosts Blocked', function () { + before(function () { + cache.clear(); + }); + + // Clear the cache. + // Create a client that uses the OIDC url and a request callback, and an + // ``ALLOWED_HOSTS`` that is an empty list. + // Assert that a ``find`` operation fails with a client-side error. + // Close the client. + context('when ALLOWED_HOSTS is empty', function () { before(function () { - client = new MongoClient( - 'mongodb://test_user1@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred', - { - authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback - } + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + ALLOWED_HOSTS: [], + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 600) } - ); + }); collection = client.db('test').collection('test'); }); - after(async () => { - client?.close(); - }); - - // - Perform a ``find`` operation. - // - Clear the cache. - it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + it('fails validation', async function () { + const error = await collection.findOne().catch(error => error); + expect(error).to.be.instanceOf(MongoInvalidArgumentError); + expect(error.message).to.include( + 'is not valid for OIDC authentication with ALLOWED_HOSTS' + ); }); }); - context('when using aws', function () { - const testTokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE; - let client; - let collection; + // Create a client that uses the url ``mongodb://localhost/?authMechanism=MONGODB-OIDC&ignored=example.com`` a request callback, and an + // ``ALLOWED_HOSTS`` that contains ["example.com"]. + // Assert that a ``find`` operation fails with a client-side error. + // Close the client. + context('when ALLOWED_HOSTS does not match', function () { + beforeEach(function () { + this.currentTest.skipReason = 'Will fail URI parsing as ignored is not a valid option'; + this.skip(); + // client = new MongoClient( + // 'mongodb://localhost/?authMechanism=MONGODB-OIDC&ignored=example.com', + // { + // authMechanismProperties: { + // ALLOWED_HOSTS: ['example.com'], + // REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 600) + // } + // } + // ); + // collection = client.db('test').collection('test'); + }); - after(async () => { - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = testTokenFile; - client?.close(); + it('fails validation', async function () { + // try { + // await collection.findOne(); + // } catch (error) { + // expect(error).to.be.instanceOf(MongoInvalidArgumentError); + // expect(error.message).to.include('Host does not match provided ALLOWED_HOSTS values'); + // } }); + }); - before(async () => { - // - Set the ``AWS_WEB_IDENTITY_TOKEN_FILE`` environment variable to the location - // of the ``test_user1`` generated token file. - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = `${process.env.OIDC_TOKEN_DIR}/test_user1`; - // - Create a client with a url of the form - // ``mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred``. - client = new MongoClient( - 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred' - ); + // Create a client that uses the url ``mongodb://evilmongodb.com`` a request + // callback, and an ``ALLOWED_HOSTS`` that contains ``*mongodb.com``. + // Assert that a ``find`` operation fails with a client-side error. + // Close the client. + context('when ALLOWED_HOSTS is invalid', function () { + before(function () { + client = new MongoClient('mongodb://evilmongodb.com/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + ALLOWED_HOSTS: ['*mongodb.com'], + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 600) + } + }); collection = client.db('test').collection('test'); }); - // - Perform a ``find`` operation. - it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + it('fails validation', async function () { + const error = await collection.findOne().catch(error => error); + expect(error).to.be.instanceOf(MongoInvalidArgumentError); + expect(error.message).to.include( + 'is not valid for OIDC authentication with ALLOWED_HOSTS' + ); }); }); }); - context('when authenticating with user 2', function () { - context('when using a callback', function () { - let client; - let collection; - // - Create a request callback that reads in the generated ``test_user2`` token file. - const requestCallback = async () => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user2`, { - encoding: 'utf8' - }); - return { accessToken: token }; - }; - // - Create a client with a url of the form - // ``mongodb://test_user2@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred`` - // and the OIDC request callback. - before(function () { - client = new MongoClient( - 'mongodb://test_user2@localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred', - { - authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback - } - } - ); - collection = client.db('test').collection('test'); - }); + describe('1.7 Lock Avoids Extra Callback Calls', function () { + let requestCounter = 0; + + before(function () { + cache.clear(); + }); - after(async () => { - client?.close(); + const requestCallback = async () => { + requestCounter++; + if (requestCounter > 1) { + throw new Error('Request callback was entered simultaneously.'); + } + const token = await readFile(path.join(process.env.OIDC_TOKEN_DIR, 'test_user1'), { + encoding: 'utf8' }); + await sleep(3000); + requestCounter--; + return generateResult(token, 300); + }; + const refreshCallback = createRefreshCallback(); + const requestSpy = sinon.spy(requestCallback); + const refreshSpy = sinon.spy(refreshCallback); - // - Perform a ``find`` operation. - // - Clear the cache. - it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); + const createClient = () => { + return new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: requestSpy, + REFRESH_TOKEN_CALLBACK: refreshSpy + } }); + }; + + const authenticate = async () => { + const client = createClient(); + await client.db('test').collection('test').findOne(); + await client.close(); + }; + + const testPromise = async () => { + await authenticate(); + await authenticate(); + }; + + // Clear the cache. + // Create a request callback that returns a token that will expire soon, and + // a refresh callback. Ensure that the request callback has a time delay, and + // that we can record the number of times each callback is called. + // Spawn two threads that do the following: + // - Create a client with the callbacks. + // - Run a find operation that succeeds. + // - Close the client. + // - Create a new client with the callbacks. + // - Run a find operation that succeeds. + // - Close the client. + // Join the two threads. + // Ensure that the request callback has been called once, and the refresh + // callback has been called twice. + it('does not simultaneously enter a callback', async function () { + await Promise.all([testPromise(), testPromise()]); + // The request callback will get called twice, but will not be entered + // simultaneously. If it does, the function will throw and we'll have + // and exception here. + expect(requestSpy).to.have.been.calledTwice; + expect(refreshSpy).to.have.been.calledTwice; }); + }); + }); - context('when using aws', function () { - let client; - let collection; - const testTokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE; + describe('2. AWS Automatic Auth', function () { + let client: MongoClient; + let collection: Collection; - after(async () => { - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = testTokenFile; - client?.close(); - }); + afterEach(async function () { + await client?.close(); + }); - before(async () => { - // - Set the ``AWS_WEB_IDENTITY_TOKEN_FILE`` environment variable to the location - // of the ``test_user2`` generated token file. - process.env.AWS_WEB_IDENTITY_TOKEN_FILE = `${process.env.OIDC_TOKEN_DIR}/test_user2`; - // - Create a client with a url of the form - // ``mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred``. - client = new MongoClient( - 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred' - ); - collection = client.db('test').collection('test'); - }); + describe('2.1 Single Principal', function () { + before(function () { + client = new MongoClient( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws' + ); + collection = client.db('test').collection('test'); + }); - // - Perform a ``find`` operation. - it('successfully authenticates', async function () { - const doc = await collection.findOne(); - expect(doc).to.equal(null); - }); + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws. + // Perform a find operation that succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; }); }); - context('when not providing a user', function () { - it('fails on option parsing', function () { - expect(() => { - new MongoClient( - 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&directConnection=true&readPreference=secondaryPreferred' - ); - }).to.throw(MongoInvalidArgumentError, /PROVIDER_NAME|REQUEST_TOKEN_CALLBACK/); + describe('2.2 Multiple Principal User 1', function () { + before(function () { + client = new MongoClient( + 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred' + ); + collection = client.db('test').collection('test'); + }); + + // Create a client with a url of the form mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred. + // Perform a find operation that succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + }); + }); + + describe('2.3 Multiple Principal User 2', function () { + let tokenFile; + + before(function () { + tokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE; + process.env.AWS_WEB_IDENTITY_TOKEN_FILE = path.join( + process.env.OIDC_TOKEN_DIR, + 'test_user2' + ); + client = new MongoClient( + 'mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred' + ); + collection = client.db('test').collection('test'); + }); + + after(function () { + process.env.AWS_WEB_IDENTITY_TOKEN_FILE = tokenFile; + }); + + // Set the AWS_WEB_IDENTITY_TOKEN_FILE environment variable to the location of valid test_user2 credentials. + // Create a client with a url of the form mongodb://localhost:27018/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws&directConnection=true&readPreference=secondaryPreferred. + // Perform a find operation that succeeds. + // Close the client. + // Restore the AWS_WEB_IDENTITY_TOKEN_FILE environment variable to the location of valid test_user2 credentials. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + }); + }); + + describe('2.4 Allowed Hosts Ignored', function () { + before(function () { + client = new MongoClient( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws', + { + authMechanismProperties: { + ALLOWED_HOSTS: [] + } + } + ); + collection = client.db('test').collection('test'); + }); + + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws, and an ALLOWED_HOSTS that is an empty list. + // Assert that a find operation succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; }); }); }); - describe('4. Invalid Callbacks', function () { - // - Any callback returns null - context('when the callback returns null', function () { - let client; - const requestCallback = async () => { - return null; + describe('3. Callback Validation', function () { + let client: MongoClient; + let collection: Collection; + + afterEach(async function () { + await client?.close(); + }); + + describe('3.1 Valid Callbacks', function () { + const requestSpy = sinon.spy(createRequestCallback('test_user1', 60)); + const refreshSpy = sinon.spy(createRefreshCallback()); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestSpy, + REFRESH_TOKEN_CALLBACK: refreshSpy }; + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + collection = client.db('test').collection('test'); + await collection.findOne(); + expect(requestSpy).to.have.been.calledOnce; + await client.close(); + }); + + // Clear the cache. + // Create request and refresh callback that validate their inputs and return a valid token. The request callback must return a token that expires in one minute. + // Create a client that uses the above callbacks. + // Perform a find operation that succeeds. Verify that the request callback was called with the appropriate inputs, including the timeout parameter if possible. Ensure that there are no unexpected fields. + // Perform another find operation that succeeds. Verify that the refresh callback was called with the appropriate inputs, including the timeout parameter if possible. + // Close the client. + it('successfully authenticates with the request and refresh callbacks', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + collection = client.db('test').collection('test'); + await collection.findOne(); + expect(refreshSpy).to.have.been.calledOnce; + }); + }); + + describe('3.2 Request Callback Returns Null', function () { before(function () { + cache.clear(); client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback + REQUEST_TOKEN_CALLBACK: () => { + return Promise.resolve(null); + } } }); + collection = client.db('test').collection('test'); }); - after(async () => { - client?.close(); - }); - - it('raises an error', async function () { + // Clear the cache. + // Create a client with a request callback that returns null. + // Perform a find operation that fails. + // Close the client. + it('fails authentication', async function () { try { - await client.connect(); - fail('Invalid request callbacks must throw on connect'); - } catch (error) { - expect(error.message).to.include('REQUEST_TOKEN_CALLBACK'); + await collection.findOne(); + expect.fail('Expected OIDC auth to fail with null return from request callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); } }); }); - // - Any callback returns unexpected result - context('then the callback returns an unexpected result', function () { - let client; - const requestCallback = async () => { - return { unexpected: 'test' }; + describe('3.3 Refresh Callback Returns Null', function () { + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 60), + REFRESH_TOKEN_CALLBACK: () => { + return Promise.resolve(null); + } }; - before(function () { + before(async function () { + cache.clear(); client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { - authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback - } + authMechanismProperties: authMechanismProperties }); + collection = client.db('test').collection('test'); + await collection.findOne(); + await client.close(); }); - after(async () => { - client?.close(); - }); - - it('raises an error', async function () { + // Clear the cache. + // Create request callback that returns a valid token that will expire in a minute, and a refresh callback that returns null. + // Perform a find operation that succeeds. + // Perform a find operation that fails. + // Close the client. + it('fails authentication on refresh', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); try { - await client.connect(); - fail('Invalid request callbacks must throw on connect'); - } catch (error) { - expect(error.message).to.include('REQUEST_TOKEN_CALLBACK'); + await client.db('test').collection('test').findOne(); + expect.fail('Expected OIDC auth to fail with invlid return from refresh callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); } }); }); - }); - // Drivers MUST ensure that they are testing the ability to cache credentials. - // Drivers will need to be able to query and override the cached credentials to - // verify usage. Unless otherwise specified, the tests MUST be performed with - // the authorization code workflow with and without a provided refresh callback. - // If desired, the caching tests can be done using mock server responses. - describe('5. Caching', function () { - let requestInvokations = 0; - let refreshInvokations = 0; - const cache = OIDC_WORKFLOWS.get('callback').cache; - // - Give a callback response with a valid accessToken and an expiresInSeconds - // that is within one minute. - // - Validate the request callback inputs, including the timeout parameter if possible. - const requestCallback = async (principalName, serverResult, timeout) => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { - encoding: 'utf8' - }); + describe('3.4 Request Callback Returns Invalid Data', function () { + context('when the request callback has missing fields', function () { + before(function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: () => { + return Promise.resolve({}); + } + } + }); + collection = client.db('test').collection('test'); + }); - expect(principalName).to.equal('test_user1'); - expect(serverResult).to.have.property('clientId'); - expect(timeout).to.equal(300000); - requestInvokations++; + // Clear the cache. + // Create a client with a request callback that returns data not conforming to the OIDCRequestTokenResult with missing field(s). + // Perform a find operation that fails. + // Close the client. + it('fails authentication', async function () { + try { + await collection.findOne(); + expect.fail('Expected OIDC auth to fail with invlid return from request callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); + } + }); + }); - return { accessToken: token, expiresInSeconds: 30 }; - }; + context('when the request callback has extra fields', function () { + before(function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 60, { foo: 'bar' }) + } + }); + collection = client.db('test').collection('test'); + }); - const refreshCallback = async (principalName, serverResult, tokenResult, timeout) => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { - encoding: 'utf8' + // Create a client with a request callback that returns data not conforming to the OIDCRequestTokenResult with extra field(s). + // Perform a find operation that fails. + // Close the client. + it('fails authentication', async function () { + try { + await collection.findOne(); + expect.fail('Expected OIDC auth to fail with extra fields from request callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); + } + }); }); + }); - expect(principalName).to.equal('test_user1'); - expect(serverResult).to.have.property('clientId'); - expect(tokenResult.accessToken).to.equal(token); - expect(timeout).to.equal(300000); - refreshInvokations++; + describe('3.5 Refresh Callback Returns Missing Data', function () { + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 60), + REFRESH_TOKEN_CALLBACK: () => { + return Promise.resolve({}); + } + }; - return { accessToken: token, expiresInSeconds: 30 }; - }; + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + await client.close(); + }); - beforeEach(() => { - requestInvokations = 0; - refreshInvokations = 0; + // Clear the cache. + // Create request callback that returns a valid token that will expire in a minute, and a refresh callback that returns data not conforming to the OIDCRequestTokenResult with missing field(s). + // Create a client with the callbacks. + // Perform a find operation that succeeds. + // Close the client. + // Create a new client with the same callbacks. + // Perform a find operation that fails. + // Close the client. + it('fails authentication on the refresh', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + try { + await client.db('test').collection('test').findOne(); + expect.fail('Expected OIDC auth to fail with missing data from refresh callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); + } + }); }); - context('when calling the request callback', function () { - let client; - let collection; + describe('3.6 Refresh Callback Returns Extra Data', function () { + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 60), + REFRESH_TOKEN_CALLBACK: createRefreshCallback('test_user1', 60, { foo: 'bar' }) + }; - // - Clear the cache. - before(function () { + before(async function () { cache.clear(); - - // - Create a new client with a request callback and a refresh callback. - // Both callbacks will read the contents of the AWS_WEB_IDENTITY_TOKEN_FILE - // location to obtain a valid access token. - client = new MongoClient('mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC', { - authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback, - REFRESH_TOKEN_CALLBACK: refreshCallback - } + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties }); - collection = client.db('test').collection('test'); + await client.db('test').collection('test').findOne(); + await client.close(); }); - after(async () => { - client?.close(); + // Clear the cache. + // Create request callback that returns a valid token that will expire in a minute, and a refresh callback that returns data not conforming to the OIDCRequestTokenResult with extra field(s). + // Create a client with the callbacks. + // Perform a find operation that succeeds. + // Close the client. + // Create a new client with the same callbacks. + // Perform a find operation that fails. + // Close the client. + it('fails authentication on the refresh', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + try { + await client.db('test').collection('test').findOne(); + expect.fail('Expected OIDC auth to fail with extra fields from refresh callback'); + } catch (e) { + expect(e).to.be.instanceOf(MongoMissingCredentialsError); + expect(e.message).to.include( + 'User provided OIDC callbacks must return a valid object with an accessToken' + ); + } }); + }); + }); - // - Ensure that a find operation adds credentials to the cache. - it('adds credentials to the cache', async function () { - await collection.findOne(); - expect(cache.entries.size).to.equal(1); + describe('4. Cached Credentials', function () { + let client: MongoClient; + let collection: Collection; + + afterEach(async function () { + await client?.close(); + }); + + describe('4.1 Cache with refresh', function () { + const requestCallback = createRequestCallback('test_user1', 60); + const refreshSpy = sinon.spy(createRefreshCallback('test_user1', 60)); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestCallback, + REFRESH_TOKEN_CALLBACK: refreshSpy + }; + + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + await client.close(); + }); + // Clear the cache. + // Create a new client with a request callback that gives credentials that expire in on minute. + // Ensure that a find operation adds credentials to the cache. + // Close the client. + // Create a new client with the same request callback and a refresh callback. + // Ensure that a find operation results in a call to the refresh callback. + // Close the client. + it('successfully authenticates and calls the refresh callback', async function () { + // Ensure credentials added to the cache. + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + expect(refreshSpy).to.have.been.calledOnce; }); }); - context('when calling the refresh callback', function () { - let client; - let collection; + describe('4.2 Cache with no refresh', function () { + const requestSpy = sinon.spy(createRequestCallback('test_user1', 60)); - before(function () { - // - Create a new client with the same request callback and a refresh callback. - client = new MongoClient('mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC', { + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback, - REFRESH_TOKEN_CALLBACK: refreshCallback + REQUEST_TOKEN_CALLBACK: requestSpy } }); - collection = client.db('test').collection('test'); - }); - - after(async () => { - client?.close(); + await client.db('test').collection('test').findOne(); + await client.close(); }); - // - Ensure that a find operation results in a call to the refresh callback. - // - Validate the refresh callback inputs, including the timeout parameter if possible. - // - Ensure there is a cache with credentials that will expire in less than 5 minutes, - // using a client with an appropriate request callback. - it('adds credentials to the cache', async function () { - await collection.findOne(); - expect(requestInvokations).to.equal(0); - expect(refreshInvokations).to.equal(1); - expect(cache.entries.values().next().value.expiration).to.be.below(Date.now() + 300000); + // Clear the cache. + // Create a new client with a request callback that gives credentials that expire in one minute. + // Ensure that a find operation adds credentials to the cache. + // Close the client. + // Create a new client with the a request callback but no refresh callback. + // Ensure that a find operation results in a call to the request callback. + // Close the client. + it('successfully authenticates and calls only the request callback', async function () { + expect(cache.entries.size).to.equal(1); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: requestSpy + } + }); + await client.db('test').collection('test').findOne(); + expect(requestSpy).to.have.been.calledTwice; }); }); - context('when providing no refresh callback', function () { - let client; - let collection; + describe('4.3 Cache key includes callback', function () { + const firstRequestCallback = createRequestCallback('test_user1'); + const secondRequestCallback = createRequestCallback('test_user1'); - before(function () { - // - Create a new client with the a request callback but no refresh callback. - client = new MongoClient('mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC', { + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback + REQUEST_TOKEN_CALLBACK: firstRequestCallback } }); - collection = client.db('test').collection('test'); + await client.db('test').collection('test').findOne(); + await client.close(); + }); + + // Clear the cache. + // Create a new client with a request callback that does not give an `expiresInSeconds` value. + // Ensure that a find operation adds credentials to the cache. + // Close the client. + // Create a new client with a different request callback. + // Ensure that a find operation replaces the one-time entry with a new entry to the cache. + // Close the client. + it('replaces expired entries in the cache', async function () { + expect(cache.entries.size).to.equal(1); + const initialKey = cache.entries.keys().next().value; + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: { + REQUEST_TOKEN_CALLBACK: secondRequestCallback + } + }); + await client.db('test').collection('test').findOne(); + expect(cache.entries.size).to.equal(1); + const newKey = cache.entries.keys().next().value; + expect(newKey).to.not.equal(initialKey); }); + }); - after(async () => { - client?.close(); + describe('4.4 Error clears cache', function () { + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: createRequestCallback('test_user1', 300), + REFRESH_TOKEN_CALLBACK: () => { + return Promise.resolve({}); + } + }; + + before(async function () { cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + expect(cache.entries.size).to.equal(1); + await client.close(); }); - // - Ensure that a find operation results in a call to the request callback. - it('adds credentials to the cache', async function () { - await collection.findOne(); - expect(requestInvokations).to.equal(1); - expect(refreshInvokations).to.equal(0); + // Clear the cache. + // Create a new client with a valid request callback that gives credentials that expire within 5 minutes and a refresh callback that gives invalid credentials. + // Ensure that a find operation adds a new entry to the cache. + // Ensure that a subsequent find operation results in an error. + // Ensure that the cached token has been cleared. + // Close the client. + it('clears the cache on authentication error', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + try { + await client.db('test').collection('test').findOne(); + expect.fail('Expected OIDC auth to fail with invalid fields from refresh callback'); + } catch (error) { + expect(error).to.be.instanceOf(MongoMissingCredentialsError); + expect(error.message).to.include(''); + expect(cache.entries.size).to.equal(0); + } }); }); - }); - // The driver MUST test reauthentication with MONGODB-OIDC for a read operation. - describe('6. Reauthentication', function () { - let refreshInvocations = 0; - let findStarted = 0; - let findSucceeded = 0; - let findFailed = 0; - let saslStarted = 0; - let saslSucceeded = 0; - let client; - let collection; - const cache = OIDC_WORKFLOWS.get('callback').cache; - - // - Create request and refresh callbacks that return valid credentials that - // will not expire soon. - const requestCallback = async () => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { - encoding: 'utf8' + describe('4.5 AWS Automatic workflow does not use cache', function () { + before(function () { + cache.clear(); + client = new MongoClient( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws' + ); + collection = client.db('test').collection('test'); }); - return { accessToken: token, expiresInSeconds: 300 }; - }; - const refreshCallback = async () => { - const token = await readFile(`${process.env.OIDC_TOKEN_DIR}/test_user1`, { - encoding: 'utf8' + // Clear the cache. + // Create a new client that uses the AWS automatic workflow. + // Ensure that a find operation does not add credentials to the cache. + // Close the client. + it('authenticates with no cache usage', async function () { + await collection.findOne(); + expect(cache.entries.size).to.equal(0); }); - refreshInvocations++; - return { accessToken: token, expiresInSeconds: 300 }; - }; + }); + }); - const commandStarted = event => { - if (event.commandName === 'find') { - findStarted++; - } - if (event.commandName === 'saslStart') { - saslStarted++; - } + describe('5. Speculative Authentication', function () { + let client: MongoClient; + const requestCallback = createRequestCallback('test_user1', 600); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestCallback }; - const commandSucceeded = event => { - if (event.commandName === 'find') { - findSucceeded++; - } - if (event.commandName === 'saslStart') { - saslSucceeded++; - } + // Removes the fail point. + const removeFailPoint = async () => { + return await client.db().admin().command({ + configureFailPoint: 'failCommand', + mode: 'off' + }); }; - const commandFailed = event => { - if (event.commandName === 'find') { - findFailed++; - } + // Sets up the fail point for the saslStart + const setupFailPoint = async () => { + return await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 2 + }, + data: { + failCommands: ['saslStart'], + errorCode: 18 + } + }); }; - before(function () { - // - Clear the cache - cache.clear(); - // - Create a client with the callbacks and an event listener capable of - // listening for SASL commands - client = new MongoClient('mongodb://test_user1@localhost/?authMechanism=MONGODB-OIDC', { - authMechanismProperties: { - REQUEST_TOKEN_CALLBACK: requestCallback, - REFRESH_TOKEN_CALLBACK: refreshCallback - }, - monitorCommands: true - }); - client.on('commandStarted', commandStarted); - client.on('commandSucceeded', commandSucceeded); - client.on('commandFailed', commandFailed); - collection = client.db('test').collection('test'); - }); - - after(async function () { - client.removeAllListeners('commandStarted'); - client.removeAllListeners('commandSucceeded'); - client.removeAllListeners('commandFailed'); - cache.clear(); + afterEach(async function () { + await removeFailPoint(); await client?.close(); }); - context('on the first find invokation', function () { - before(function () { - findStarted = 0; - findSucceeded = 0; - findFailed = 0; - refreshInvocations = 0; - saslStarted = 0; - saslSucceeded = 0; + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties }); + await setupFailPoint(); + await client.db('test').collection('test').findOne(); + await client.close(); + }); - // - Perform a find operation. - // - Assert that the refresh callback has not been called. - it('does not call the refresh callback', async function () { - await collection.findOne(); - expect(refreshInvocations).to.equal(0); + // Clear the cache. + // Create a client with a request callback that returns a valid token that will not expire soon. + // Set a fail point for saslStart commands of the form: + // + // { + // "configureFailPoint": "failCommand", + // "mode": { + // "times": 2 + // }, + // "data": { + // "failCommands": [ + // "saslStart" + // ], + // "errorCode": 18 + // } + // } + // + // Note + // + // The driver MUST either use a unique appName or explicitly remove the failCommand after the test to prevent leakage. + // + // Perform a find operation that succeeds. + // Close the client. + // Create a new client with the same properties without clearing the cache. + // Set a fail point for saslStart commands. + // Perform a find operation that succeeds. + // Close the client. + it('successfully speculative authenticates', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties }); + await setupFailPoint(); + const result = await client.db('test').collection('test').findOne(); + expect(result).to.be.null; }); + }); + + describe('6. Reauthentication', function () { + let client: MongoClient; + + // Removes the fail point. + const removeFailPoint = async () => { + return await client.db().admin().command({ + configureFailPoint: 'failCommand', + mode: 'off' + }); + }; + + describe('6.1 Succeeds', function () { + const requestCallback = createRequestCallback('test_user1', 600); + const refreshSpy = sinon.spy(createRefreshCallback('test_user1', 600)); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestCallback, + REFRESH_TOKEN_CALLBACK: refreshSpy + }; + const commandStartedEvents: CommandStartedEvent[] = []; + const commandSucceededEvents: CommandSucceededEvent[] = []; + const commandFailedEvents: CommandFailedEvent[] = []; + + const commandStartedListener = event => { + if (event.commandName === 'find') { + commandStartedEvents.push(event); + } + }; + const commandSucceededListener = event => { + if (event.commandName === 'find') { + commandSucceededEvents.push(event); + } + }; + const commandFailedListener = event => { + if (event.commandName === 'find') { + commandFailedEvents.push(event); + } + }; + + const addListeners = () => { + client.on('commandStarted', commandStartedListener); + client.on('commandSucceeded', commandSucceededListener); + client.on('commandFailed', commandFailedListener); + }; + + // Sets up the fail point for the find to reauthenticate. + const setupFailPoint = async () => { + return await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['find'], + errorCode: 391 + } + }); + }; - context('when a command errors and needs reauthentication', function () { - // Force a reauthenication using a failCommand of the form: before(async function () { - findStarted = 0; - findSucceeded = 0; - findFailed = 0; - refreshInvocations = 0; - saslStarted = 0; - saslSucceeded = 0; - await client.db('admin').command({ - configureFailPoint: 'failCommand', - mode: { times: 1 }, - data: { - failCommands: ['find'], - errorCode: 391 - } + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties }); - // Perform another find operation. - await collection.findOne(); + await client.db('test').collection('test').findOne(); + expect(refreshSpy).to.not.be.called; + client.close(); }); - after(async function () { - await client.db('admin').command({ - configureFailPoint: 'failCommand', - mode: 'off' + afterEach(async function () { + await removeFailPoint(); + await client.close(); + }); + + // Clear the cache. + // Create request and refresh callbacks that return valid credentials that will not expire soon. + // Create a client with the callbacks and an event listener. The following assumes that the driver does not emit saslStart or saslContinue events. If the driver does emit those events, ignore/filter them for the purposes of this test. + // Perform a find operation that succeeds. + // Assert that the refresh callback has not been called. + // Clear the listener state if possible. + // Force a reauthenication using a failCommand of the form: + // + // { + // "configureFailPoint": "failCommand", + // "mode": { + // "times": 1 + // }, + // "data": { + // "failCommands": [ + // "find" + // ], + // "errorCode": 391 + // } + // } + // + // Note + // + // the driver MUST either use a unique appName or explicitly remove the failCommand after the test to prevent leakage. + // + // Perform another find operation that succeeds. + // Assert that the refresh callback has been called once, if possible. + // Assert that the ordering of list started events is [find], , find. Note that if the listener stat could not be cleared then there will and be extra find command. + // Assert that the list of command succeeded events is [find]. + // Assert that a find operation failed once during the command execution. + // Close the client. + it('successfully reauthenticates', async function () { + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties, + monitorCommands: true }); - cache.clear(); - await client?.close(); + addListeners(); + await setupFailPoint(); + await client.db('test').collection('test').findOne(); + expect(refreshSpy).to.have.been.calledOnce; + expect(commandStartedEvents.map(event => event.commandName)).to.deep.equal([ + 'find', + 'find' + ]); + expect(commandSucceededEvents.map(event => event.commandName)).to.deep.equal(['find']); + expect(commandFailedEvents.map(event => event.commandName)).to.deep.equal(['find']); }); + }); + + describe('6.2 Retries and Succeeds with Cache', function () { + const requestCallback = createRequestCallback('test_user1', 600); + const refreshCallback = createRefreshCallback('test_user1', 600); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestCallback, + REFRESH_TOKEN_CALLBACK: refreshCallback + }; + // Sets up the fail point for the find to reauthenticate. + const setupFailPoint = async () => { + return await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['find', 'saslStart'], + errorCode: 391 + } + }); + }; - // - Assert that the refresh callback has been called, if possible. - it('calls the refresh callback', function () { - expect(refreshInvocations).to.equal(1); + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + await setupFailPoint(); }); - // - Assert that a find operation was started twice and a saslStart operation - // was started once during the command execution. - it('starts the find operation twice', function () { - expect(findStarted).to.equal(2); + afterEach(async function () { + await removeFailPoint(); + await client.close(); }); - it('starts saslStart once', function () { - expect(saslStarted).to.equal(1); + // Clear the cache. + // Create request and refresh callbacks that return valid credentials that will not expire soon. + // Perform a find operation that succeeds. + // Force a reauthenication using a failCommand of the form: + // + // { + // "configureFailPoint": "failCommand", + // "mode": { + // "times": 2 + // }, + // "data": { + // "failCommands": [ + // "find", "saslStart" + // ], + // "errorCode": 391 + // } + // } + // + // Perform a find operation that succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await client.db('test').collection('test').findOne(); + expect(result).to.be.null; }); + }); + + describe('6.3 Retries and Fails with no Cache', function () { + const requestCallback = createRequestCallback('test_user1', 600); + const refreshCallback = createRefreshCallback('test_user1', 600); + const authMechanismProperties = { + REQUEST_TOKEN_CALLBACK: requestCallback, + REFRESH_TOKEN_CALLBACK: refreshCallback + }; + // Sets up the fail point for the find to reauthenticate. + const setupFailPoint = async () => { + return await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 2 + }, + data: { + failCommands: ['find', 'saslStart'], + errorCode: 391 + } + }); + }; - // - Assert that a find operation succeeeded once and the saslStart operation - // succeeded during the command execution. - it('succeeds on the find once', function () { - expect(findSucceeded).to.equal(1); + before(async function () { + cache.clear(); + client = new MongoClient('mongodb://localhost/?authMechanism=MONGODB-OIDC', { + authMechanismProperties: authMechanismProperties + }); + await client.db('test').collection('test').findOne(); + cache.clear(); + await setupFailPoint(); }); - it('succeeds on saslStart once', function () { - expect(saslSucceeded).to.equal(1); + afterEach(async function () { + await removeFailPoint(); + await client.close(); }); - // Assert that a find operation failed once during the command execution. - it('fails on the find once', function () { - expect(findFailed).to.equal(1); + // Clear the cache. + // Create request and refresh callbacks that return valid credentials that will not expire soon. + // Perform a find operation that succeeds (to force a speculative auth). + // Clear the cache. + // Force a reauthenication using a failCommand of the form: + // + // { + // "configureFailPoint": "failCommand", + // "mode": { + // "times": 2 + // }, + // "data": { + // "failCommands": [ + // "find", "saslStart" + // ], + // "errorCode": 391 + // } + // } + // + // Perform a find operation that fails. + // Close the client. + it('fails authentication', async function () { + try { + await client.db('test').collection('test').findOne(); + expect.fail('Reauthentication must fail on the saslStart error'); + } catch (error) { + // This is the saslStart failCommand bubbled up. + expect(error).to.be.instanceOf(MongoServerError); + } }); }); }); diff --git a/test/mongodb.ts b/test/mongodb.ts index b209ffbdf2..18327be63b 100644 --- a/test/mongodb.ts +++ b/test/mongodb.ts @@ -107,10 +107,10 @@ export * from '../src/cmap/auth/mongocr'; export * from '../src/cmap/auth/mongodb_aws'; export * from '../src/cmap/auth/mongodb_oidc'; export * from '../src/cmap/auth/mongodb_oidc/aws_service_workflow'; +export * from '../src/cmap/auth/mongodb_oidc/callback_lock_cache'; export * from '../src/cmap/auth/mongodb_oidc/callback_workflow'; export * from '../src/cmap/auth/mongodb_oidc/service_workflow'; export * from '../src/cmap/auth/mongodb_oidc/token_entry_cache'; -export * from '../src/cmap/auth/mongodb_oidc/workflow'; export * from '../src/cmap/auth/plain'; export * from '../src/cmap/auth/providers'; export * from '../src/cmap/auth/scram'; diff --git a/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts b/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts new file mode 100644 index 0000000000..f7e7908142 --- /dev/null +++ b/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts @@ -0,0 +1,145 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + CallbackLockCache, + Connection, + MongoCredentials, + MongoInvalidArgumentError +} from '../../../../mongodb'; +import { sleep } from '../../../../tools/utils'; + +describe('CallbackLockCache', function () { + describe('#getCallbacks', function () { + const connection = sinon.createStubInstance(Connection); + connection.address = 'localhost:27017'; + + context('when a request callback does not exist', function () { + const credentials = new MongoCredentials({ + username: 'test_user', + password: 'pwd', + source: '$external', + mechanismProperties: {} + }); + const cache = new CallbackLockCache(); + + it('raises an error', function () { + try { + cache.getCallbacks(connection, credentials); + expect.fail('Must raise error when no request callback exists.'); + } catch (error) { + expect(error).to.be.instanceOf(MongoInvalidArgumentError); + expect(error.message).to.include( + 'Auth mechanism property REQUEST_TOKEN_CALLBACK is required' + ); + } + }); + }); + + context('when no entry exists in the cache', function () { + context('when a refresh callback exists', function () { + let requestCount = 0; + let refreshCount = 0; + + const request = async () => { + requestCount++; + if (requestCount > 1) { + throw new Error('Cannot execute request simultaneously.'); + } + await sleep(1000); + requestCount--; + return { accessToken: '' }; + }; + const refresh = async () => { + refreshCount++; + if (refreshCount > 1) { + throw new Error('Cannot execute refresh simultaneously.'); + } + await sleep(1000); + refreshCount--; + return Promise.resolve({ accessToken: '' }); + }; + const requestSpy = sinon.spy(request); + const refreshSpy = sinon.spy(refresh); + const credentials = new MongoCredentials({ + username: 'test_user', + password: 'pwd', + source: '$external', + mechanismProperties: { + REQUEST_TOKEN_CALLBACK: requestSpy, + REFRESH_TOKEN_CALLBACK: refreshSpy + } + }); + const cache = new CallbackLockCache(); + const { requestCallback, refreshCallback, callbackHash } = cache.getCallbacks( + connection, + credentials + ); + + it('puts a new entry in the cache', function () { + expect(cache.entries).to.have.lengthOf(1); + }); + + it('returns the new entry', function () { + expect(requestCallback).to.exist; + expect(refreshCallback).to.exist; + expect(callbackHash).to.exist; + }); + + it('locks the callbacks', async function () { + await Promise.allSettled([ + requestCallback(), + requestCallback(), + refreshCallback(), + refreshCallback() + ]); + expect(requestSpy).to.have.been.calledTwice; + expect(refreshSpy).to.have.been.calledTwice; + }); + }); + + context('when a refresh function does not exist', function () { + let requestCount = 0; + + const request = async () => { + requestCount++; + if (requestCount > 1) { + throw new Error('Cannot execute request simultaneously.'); + } + await sleep(1000); + requestCount--; + return Promise.resolve({ accessToken: '' }); + }; + const requestSpy = sinon.spy(request); + const credentials = new MongoCredentials({ + username: 'test_user', + password: 'pwd', + source: '$external', + mechanismProperties: { + REQUEST_TOKEN_CALLBACK: requestSpy + } + }); + const cache = new CallbackLockCache(); + const { requestCallback, refreshCallback, callbackHash } = cache.getCallbacks( + connection, + credentials + ); + + it('puts a new entry in the cache', function () { + expect(cache.entries).to.have.lengthOf(1); + }); + + it('returns the new entry', function () { + expect(requestCallback).to.exist; + expect(refreshCallback).to.not.exist; + expect(callbackHash).to.exist; + }); + + it('locks the callbacks', async function () { + await Promise.allSettled([requestCallback(), requestCallback()]); + expect(requestSpy).to.have.been.calledTwice; + }); + }); + }); + }); +}); diff --git a/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts b/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts index b71ecb17b2..048a94765f 100644 --- a/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts +++ b/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts @@ -7,18 +7,11 @@ describe('TokenEntryCache', function () { accessToken: 'test', expiresInSeconds: 100 }); - const serverResult = Object.freeze({ + issuer: 'test', clientId: '1' }); - - const fnOne = () => { - return { accessToken: 'test' }; - }; - - const fnTwo = () => { - return { accessToken: 'test' }; - }; + const callbackHash = '1'; describe('#addEntry', function () { context('when expiresInSeconds is provided', function () { @@ -26,8 +19,8 @@ describe('TokenEntryCache', function () { let entry; before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, tokenResultWithExpiration, serverResult); - entry = cache.getEntry('localhost', 'user', fnOne, fnTwo); + cache.addEntry('localhost', 'user', callbackHash, tokenResultWithExpiration, serverResult); + entry = cache.getEntry('localhost', 'user', callbackHash); }); it('adds the token result', function () { @@ -35,7 +28,7 @@ describe('TokenEntryCache', function () { }); it('adds the server result', function () { - expect(entry.serverResult).to.deep.equal(serverResult); + expect(entry.serverInfo).to.deep.equal(serverResult); }); it('creates an expiration', function () { @@ -50,8 +43,8 @@ describe('TokenEntryCache', function () { const expiredResult = Object.freeze({ accessToken: 'test' }); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, expiredResult, serverResult); - entry = cache.getEntry('localhost', 'user', fnOne, fnTwo); + cache.addEntry('localhost', 'user', callbackHash, expiredResult, serverResult); + entry = cache.getEntry('localhost', 'user', callbackHash); }); it('sets an immediate expiration', function () { @@ -69,8 +62,8 @@ describe('TokenEntryCache', function () { }); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, expiredResult, serverResult); - entry = cache.getEntry('localhost', 'user', fnOne, fnTwo); + cache.addEntry('localhost', 'user', callbackHash, expiredResult, serverResult); + entry = cache.getEntry('localhost', 'user', callbackHash); }); it('sets an immediate expiration', function () { @@ -83,7 +76,7 @@ describe('TokenEntryCache', function () { const cache = new TokenEntryCache(); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, tokenResultWithExpiration, serverResult); + cache.addEntry('localhost', 'user', callbackHash, tokenResultWithExpiration, serverResult); cache.clear(); }); @@ -101,15 +94,15 @@ describe('TokenEntryCache', function () { }); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, tokenResultWithExpiration, serverResult); - cache.addEntry('localhost', 'user2', fnOne, fnTwo, nonExpiredResult, serverResult); + cache.addEntry('localhost', 'user', callbackHash, tokenResultWithExpiration, serverResult); + cache.addEntry('localhost', 'user2', callbackHash, nonExpiredResult, serverResult); cache.deleteExpiredEntries(); }); it('deletes all expired tokens from the cache 5 minutes before expiredInSeconds', function () { expect(cache.entries.size).to.equal(1); - expect(cache.getEntry('localhost', 'user', fnOne, fnTwo)).to.not.exist; - expect(cache.getEntry('localhost', 'user2', fnOne, fnTwo)).to.exist; + expect(cache.getEntry('localhost', 'user', callbackHash)).to.not.exist; + expect(cache.getEntry('localhost', 'user2', callbackHash)).to.exist; }); }); @@ -117,12 +110,12 @@ describe('TokenEntryCache', function () { const cache = new TokenEntryCache(); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, tokenResultWithExpiration, serverResult); - cache.deleteEntry('localhost', 'user', fnOne, fnTwo); + cache.addEntry('localhost', 'user', callbackHash, tokenResultWithExpiration, serverResult); + cache.deleteEntry('localhost', 'user', callbackHash); }); it('deletes the entry', function () { - expect(cache.getEntry('localhost', 'user', fnOne, fnTwo)).to.not.exist; + expect(cache.getEntry('localhost', 'user', callbackHash)).to.not.exist; }); }); @@ -130,13 +123,13 @@ describe('TokenEntryCache', function () { const cache = new TokenEntryCache(); before(function () { - cache.addEntry('localhost', 'user', fnOne, fnTwo, tokenResultWithExpiration, serverResult); - cache.addEntry('localhost', 'user2', fnOne, fnTwo, tokenResultWithExpiration, serverResult); + cache.addEntry('localhost', 'user', callbackHash, tokenResultWithExpiration, serverResult); + cache.addEntry('localhost', 'user2', callbackHash, tokenResultWithExpiration, serverResult); }); context('when there is a matching entry', function () { it('returns the entry', function () { - expect(cache.getEntry('localhost', 'user', fnOne, fnTwo)?.tokenResult).to.equal( + expect(cache.getEntry('localhost', 'user', callbackHash)?.tokenResult).to.equal( tokenResultWithExpiration ); }); @@ -144,7 +137,7 @@ describe('TokenEntryCache', function () { context('when there is no matching entry', function () { it('returns undefined', function () { - expect(cache.getEntry('localhost', 'user1', fnOne, fnTwo)).to.equal(undefined); + expect(cache.getEntry('localhost', 'user1', callbackHash)).to.equal(undefined); }); }); }); diff --git a/test/unit/connection_string.test.ts b/test/unit/connection_string.test.ts index 0f947d8959..eb765a8186 100644 --- a/test/unit/connection_string.test.ts +++ b/test/unit/connection_string.test.ts @@ -5,6 +5,7 @@ import * as sinon from 'sinon'; import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism, + DEFAULT_ALLOWED_HOSTS, FEATURE_FLAGS, MongoAPIError, MongoClient, @@ -211,6 +212,72 @@ describe('Connection String', function () { expect(options.readConcern.level).to.equal('local'); }); + context('when auth mechanism is MONGODB-OIDC', function () { + context('when ALLOWED_HOSTS is in the URI', function () { + it('raises an error', function () { + expect(() => { + parseOptions( + 'mongodb://localhost/?authMechanismProperties=PROVIDER_NAME:aws,ALLOWED_HOSTS:[localhost]&authMechanism=MONGODB-OIDC' + ); + }).to.throw( + MongoParseError, + 'Auth mechanism property ALLOWED_HOSTS is not allowed in the connection string.' + ); + }); + }); + + context('when ALLOWED_HOSTS is in the options', function () { + context('when it is an array of strings', function () { + const hosts = ['*.example.com']; + + it('sets the allowed hosts property', function () { + const options = parseOptions( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws', + { + authMechanismProperties: { + ALLOWED_HOSTS: hosts + } + } + ); + expect(options.credentials.mechanismProperties).to.deep.equal({ + PROVIDER_NAME: 'aws', + ALLOWED_HOSTS: hosts + }); + }); + }); + + context('when it is not an array of strings', function () { + it('raises an error', function () { + expect(() => { + parseOptions( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws', + { + authMechanismProperties: { + ALLOWED_HOSTS: [1, 2, 3] + } + } + ); + }).to.throw( + MongoInvalidArgumentError, + 'Auth mechanism property ALLOWED_HOSTS must be an array of strings.' + ); + }); + }); + }); + + context('when ALLOWED_HOSTS is not in the options', function () { + it('sets the default value', function () { + const options = parseOptions( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:aws' + ); + expect(options.credentials.mechanismProperties).to.deep.equal({ + PROVIDER_NAME: 'aws', + ALLOWED_HOSTS: DEFAULT_ALLOWED_HOSTS + }); + }); + }); + }); + it('should parse `authMechanismProperties`', function () { const options = parseOptions( 'mongodb://user%40EXAMPLE.COM:secret@localhost/?authMechanismProperties=SERVICE_NAME:other,SERVICE_REALM:blah,CANONICALIZE_HOST_NAME:true,SERVICE_HOST:example.com&authMechanism=GSSAPI' diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index df0efc599e..519aef7845 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -6,6 +6,7 @@ import { compareObjectId, eachAsync, HostAddress, + hostMatchesWildcards, isHello, LEGACY_HELLO_COMMAND, List, @@ -18,6 +19,148 @@ import { } from '../mongodb'; describe('driver utils', function () { + describe('.hostMatchesWildcards', function () { + context('when using domains', function () { + context('when using exact match', function () { + context('when the host matches at least one', function () { + it('returns true', function () { + expect(hostMatchesWildcards('localhost', ['localhost', 'other'])).to.be.true; + }); + }); + + context('when the host does not match any', function () { + it('returns false', function () { + expect(hostMatchesWildcards('localhost', ['test1', 'test2'])).to.be.false; + }); + }); + + context('when the host matches a FQDN', function () { + it('returns true', function () { + expect(hostMatchesWildcards('mongodb.net', ['mongodb.net', 'other'])).to.be.true; + }); + }); + + context('when the host does not match a FQDN', function () { + it('returns false', function () { + expect(hostMatchesWildcards('mongodb.net', ['mongodb.com', 'other'])).to.be.false; + }); + }); + + context('when the host matches a FQDN with subdomain', function () { + it('returns true', function () { + expect( + hostMatchesWildcards('prod.mongodb.net', ['prod.mongodb.net', 'other']) + ).to.be.true; + }); + }); + + context('when the host does not match a FQDN with subdomain', function () { + it('returns false', function () { + expect( + hostMatchesWildcards('prod.mongodb.net', ['dev.mongodb.net', 'prod.mongodb.com']) + ).to.be.false; + }); + }); + }); + + context('when using a leading * with domains', function () { + context('when the host matches at least one', function () { + it('returns true', function () { + expect(hostMatchesWildcards('localhost', ['*.localhost', 'other'])).to.be.true; + }); + }); + + context('when the host does not match any', function () { + it('returns false', function () { + expect(hostMatchesWildcards('localhost', ['*.test1', 'test2'])).to.be.false; + }); + }); + + context('when the wildcard does not start with *.', function () { + it('returns false', function () { + expect(hostMatchesWildcards('evilmongodb.com', ['*mongodb.com', 'test2'])).to.be.false; + }); + }); + + context('when the host matches a FQDN', function () { + it('returns true', function () { + expect(hostMatchesWildcards('mongodb.net', ['*.mongodb.net', 'other'])).to.be.true; + }); + }); + + context('when the host does not match a FQDN', function () { + it('returns false', function () { + expect(hostMatchesWildcards('mongodb.net', ['*.mongodb.com', 'other'])).to.be.false; + }); + }); + + context('when the host matches a FQDN with subdomain', function () { + it('returns true', function () { + expect( + hostMatchesWildcards('prod.mongodb.net', ['*.prod.mongodb.net', 'other']) + ).to.be.true; + }); + }); + + context('when the host does not match a FQDN with subdomain', function () { + it('returns false', function () { + expect( + hostMatchesWildcards('prod.mongodb.net', ['*.dev.mongodb.net', '*.prod.mongodb.com']) + ).to.be.false; + }); + }); + }); + }); + + context('when using IP addresses', function () { + context('when using IPv4', function () { + context('when the host matches at least one', function () { + it('returns true', function () { + expect(hostMatchesWildcards('127.0.0.1', ['127.0.0.1', 'other'])).to.be.true; + }); + }); + + context('when the host does not match any', function () { + it('returns false', function () { + expect(hostMatchesWildcards('127.0.0.1', ['127.0.0.2', 'test2'])).to.be.false; + }); + }); + }); + + context('when using IPv6', function () { + context('when the host matches at least one', function () { + it('returns true', function () { + expect(hostMatchesWildcards('::1', ['::1', 'other'])).to.be.true; + }); + }); + + context('when the host does not match any', function () { + it('returns false', function () { + expect(hostMatchesWildcards('::1', ['::2', 'test2'])).to.be.false; + }); + }); + }); + }); + + context('when using unix domain sockets', function () { + context('when the host matches at least one', function () { + it('returns true', function () { + expect( + hostMatchesWildcards('/tmp/mongodb-27017.sock', ['*/mongodb-27017.sock', 'other']) + ).to.be.true; + }); + }); + + context('when the host does not match any', function () { + it('returns false', function () { + expect( + hostMatchesWildcards('/tmp/mongodb-27017.sock', ['*/mongod-27017.sock', 'test2']) + ).to.be.false; + }); + }); + }); + }); + context('eachAsync()', function () { it('should callback with an error', function (done) { eachAsync(