Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-5036): reauthenticate OIDC and retry #3589

Merged
merged 17 commits into from
Mar 17, 2023
Merged
2 changes: 1 addition & 1 deletion .evergreen/setup-oidc-roles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ set -o xtrace # Write all commands first to stderr
cd ${DRIVERS_TOOLS}/.evergreen/auth_oidc
. ./activate-authoidcvenv.sh

${DRIVERS_TOOLS}/mongodb/bin/mongosh setup_oidc.js
${DRIVERS_TOOLS}/mongodb/bin/mongosh "mongodb://localhost:27017,localhost:27018/?replicaSet=oidc-repl0&readPreference=primary" setup_oidc.js
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,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": "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": "TRACE_SOCKETS=true mocha --config test/manual/mocharc.json test/manual/mongodb_oidc.prose.test.ts",
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
"check:ocsp": "mocha --config test/manual/mocharc.json test/manual/ocsp_support.test.js",
"check:kerberos": "mocha --config test/manual/mocharc.json test/manual/kerberos.test.js",
"check:tls": "mocha --config test/manual/mocharc.json test/manual/tls_support.test.js",
Expand Down
22 changes: 21 additions & 1 deletion src/cmap/auth/auth_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ import type { HandshakeDocument } from '../connect';
import type { Connection, ConnectionOptions } from '../connection';
import type { MongoCredentials } from './mongo_credentials';

/** @internal */
export type AuthContextOptions = ConnectionOptions & ClientMetadataOptions;

/** Context used during authentication */
/**
* Context used during authentication
* @internal
*/
export class AuthContext {
/** The connection to authenticate */
connection: Connection;
/** The credentials to use for authentication */
credentials?: MongoCredentials;
/** If the context if for reauthentication. */
dariakp marked this conversation as resolved.
Show resolved Hide resolved
reauthenticating = false;
/** The options passed to the `connect` method */
options: AuthContextOptions;

Expand Down Expand Up @@ -57,4 +63,18 @@ export class AuthProvider {
// TODO(NODE-3483): Replace this with MongoMethodOverrideError
callback(new MongoRuntimeError('`auth` method must be overridden by subclass'));
}

/**
* Reauthenticate.
* @param context - The shared auth context.
* @param callback - The callback.
*/
reauth(context: AuthContext, callback: Callback): void {
dariakp marked this conversation as resolved.
Show resolved Hide resolved
context.reauthenticating = true;
const cb: Callback = (error, result) => {
context.reauthenticating = false;
callback(error, result);
};
this.auth(context, cb);
}
}
12 changes: 12 additions & 0 deletions src/cmap/auth/mongo_credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,20 @@ export interface AuthMechanismProperties extends Document {
SERVICE_REALM?: string;
CANONICALIZE_HOST_NAME?: GSSAPICanonicalizationValue;
AWS_SESSION_TOKEN?: string;
/**
* @alpha
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
* @experimental
*/
REQUEST_TOKEN_CALLBACK?: OIDCRequestFunction;
/**
* @alpha
* @experimental
*/
REFRESH_TOKEN_CALLBACK?: OIDCRefreshFunction;
/**
* @alpha
* @experimental
*/
PROVIDER_NAME?: 'aws';
}

Expand Down
25 changes: 19 additions & 6 deletions src/cmap/auth/mongodb_oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { AwsServiceWorkflow } from './mongodb_oidc/aws_service_workflow';
import { CallbackWorkflow } from './mongodb_oidc/callback_workflow';
import type { Workflow } from './mongodb_oidc/workflow';

/** @public */
/**
* @alpha
* @experimental
*/
export interface OIDCMechanismServerStep1 {
authorizationEndpoint?: string;
tokenEndpoint?: string;
Expand All @@ -21,21 +24,30 @@ export interface OIDCMechanismServerStep1 {
requestScopes?: string[];
}

/** @public */
/**
* @alpha
* @experimental
*/
export interface OIDCRequestTokenResult {
accessToken: string;
expiresInSeconds?: number;
refreshToken?: string;
}

/** @public */
/**
* @alpha
* @experimental
*/
export type OIDCRequestFunction = (
principalName: string,
serverResult: OIDCMechanismServerStep1,
timeout: AbortSignal | number
) => Promise<OIDCRequestTokenResult>;

/** @public */
/**
* @alpha
* @experimental
*/
export type OIDCRefreshFunction = (
principalName: string,
serverResult: OIDCMechanismServerStep1,
Expand All @@ -52,6 +64,7 @@ OIDC_WORKFLOWS.set('aws', new AwsServiceWorkflow());

/**
* OIDC auth provider.
* @experimental
*/
export class MongoDBOIDC extends AuthProvider {
/**
Expand All @@ -65,7 +78,7 @@ export class MongoDBOIDC extends AuthProvider {
* Authenticate using OIDC
*/
override auth(authContext: AuthContext, callback: Callback): void {
const { connection, credentials, response } = authContext;
const { connection, credentials, response, reauthenticating } = authContext;

if (response?.speculativeAuthenticate) {
return callback();
Expand All @@ -86,7 +99,7 @@ export class MongoDBOIDC extends AuthProvider {
)
);
}
workflow.execute(connection, credentials).then(
workflow.execute(connection, credentials, reauthenticating).then(
result => {
return callback(undefined, result);
},
Expand Down
10 changes: 7 additions & 3 deletions src/cmap/auth/mongodb_oidc/callback_workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export class CallbackWorkflow implements Workflow {
* - put the new entry in the cache.
* - execute step two.
*/
async execute(connection: Connection, credentials: MongoCredentials): Promise<Document> {
async execute(
connection: Connection,
credentials: MongoCredentials,
reauthenticate = false
): Promise<Document> {
const request = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK;
const refresh = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK;

Expand All @@ -69,8 +73,8 @@ export class CallbackWorkflow implements Workflow {
refresh || null
);
if (entry) {
// Check if the entry is not expired.
if (entry.isValid()) {
// 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);
Expand Down
6 changes: 5 additions & 1 deletion src/cmap/auth/mongodb_oidc/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ 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): Promise<Document>;
execute(
connection: Connection,
credentials: MongoCredentials,
reauthenticate?: boolean
baileympearson marked this conversation as resolved.
Show resolved Hide resolved
): Promise<Document>;

/**
* Get the document to add for speculative authentication.
Expand Down
1 change: 1 addition & 0 deletions src/cmap/auth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const AuthMechanism = Object.freeze({
MONGODB_SCRAM_SHA1: 'SCRAM-SHA-1',
MONGODB_SCRAM_SHA256: 'SCRAM-SHA-256',
MONGODB_X509: 'MONGODB-X509',
/** @experimental */
MONGODB_OIDC: 'MONGODB-OIDC'
} as const);

Expand Down
4 changes: 2 additions & 2 deletions src/cmap/auth/scram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ class ScramSHA extends AuthProvider {
}

override auth(authContext: AuthContext, callback: Callback) {
const response = authContext.response;
if (response && response.speculativeAuthenticate) {
const { reauthenticating, response } = authContext;
if (response?.speculativeAuthenticate && !reauthenticating) {
continueScramConversation(
this.cryptoMethod,
response.speculativeAuthenticate,
Expand Down
4 changes: 3 additions & 1 deletion src/cmap/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import {
MIN_SUPPORTED_WIRE_VERSION
} from './wire_protocol/constants';

const AUTH_PROVIDERS = new Map<AuthMechanism | string, AuthProvider>([
/** @internal */
export const AUTH_PROVIDERS = new Map<AuthMechanism | string, AuthProvider>([
[AuthMechanism.MONGODB_AWS, new MongoDBAWS()],
[AuthMechanism.MONGODB_CR, new MongoCR()],
[AuthMechanism.MONGODB_GSSAPI, new GSSAPI()],
Expand Down Expand Up @@ -117,6 +118,7 @@ function performInitialHandshake(
}

const authContext = new AuthContext(conn, credentials, options);
conn.authContext = authContext;
prepareHandshakeDocument(authContext, (err, handshakeDoc) => {
if (err || !handshakeDoc) {
return callback(err);
Expand Down
4 changes: 3 additions & 1 deletion src/cmap/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
uuidV4
} from '../utils';
import type { WriteConcern } from '../write_concern';
import type { AuthContext } from './auth/auth_provider';
import type { MongoCredentials } from './auth/mongo_credentials';
import {
CommandFailedEvent,
Expand Down Expand Up @@ -126,7 +127,6 @@ export interface ConnectionOptions
noDelay?: boolean;
socketTimeoutMS?: number;
cancellationToken?: CancellationToken;

metadata: ClientMetadata;
}

Expand Down Expand Up @@ -164,6 +164,8 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
cmd: Document,
options: CommandOptions | undefined
) => Promise<Document>;
/** @internal */
authContext?: AuthContext;

/**@internal */
[kDelayedTimeoutId]: NodeJS.Timeout | null;
Expand Down
87 changes: 74 additions & 13 deletions src/cmap/connection_pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ import {
CONNECTION_READY
} from '../constants';
import {
AnyError,
MONGODB_ERROR_CODES,
MongoError,
MongoInvalidArgumentError,
MongoMissingCredentialsError,
MongoNetworkError,
MongoRuntimeError,
MongoServerError
} from '../error';
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
import type { Server } from '../sdam/server';
import { Callback, eachAsync, List, makeCounter } from '../utils';
import { connect } from './connect';
import { AUTH_PROVIDERS, connect } from './connect';
import { Connection, ConnectionEvents, ConnectionOptions } from './connection';
import {
ConnectionCheckedInEvent,
Expand Down Expand Up @@ -537,32 +540,30 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
withConnection(
conn: Connection | undefined,
fn: WithConnectionCallback,
callback?: Callback<Connection>
callback: Callback<Connection>
): void {
if (conn) {
// use the provided connection, and do _not_ check it in after execution
fn(undefined, conn, (fnErr, result) => {
if (typeof callback === 'function') {
if (fnErr) {
callback(fnErr);
} else {
callback(undefined, result);
}
if (fnErr) {
return this.withReauthentication(fnErr, conn, fn, callback);
}
callback(undefined, result);
});

return;
}

this.checkOut((err, conn) => {
// don't callback with `err` here, we might want to act upon it inside `fn`
fn(err as MongoError, conn, (fnErr, result) => {
if (typeof callback === 'function') {
if (fnErr) {
callback(fnErr);
if (fnErr) {
if (conn) {
this.withReauthentication(fnErr, conn, fn, callback);
} else {
callback(undefined, result);
callback(fnErr);
}
} else {
callback(undefined, result);
}

if (conn) {
Expand All @@ -572,6 +573,66 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
});
}

private withReauthentication(
fnErr: AnyError,
conn: Connection,
fn: WithConnectionCallback,
callback: Callback<Connection>
) {
if ((fnErr as MongoError).code === MONGODB_ERROR_CODES.Reauthenticate) {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
this.reauthenticate(conn, fn, (error, res) => {
if (error) {
return callback(error);
}
callback(undefined, res);
});
} else {
callback(fnErr);
}
}

/**
* Reauthenticate on the same connection and then retry the operation.
*/
private reauthenticate(
connection: Connection,
fn: WithConnectionCallback,
callback: Callback
): void {
const authContext = connection.authContext;
if (!authContext) {
return callback(new MongoRuntimeError('No auth context found on connection.'));
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
}
const credentials = authContext.credentials;
if (!credentials) {
return callback(
new MongoMissingCredentialsError(
'Connection is missing credentials when asked to reauthenticate'
)
);
}
const resolvedCredentials = credentials.resolveAuthMechanism(connection.hello || undefined);
const provider = AUTH_PROVIDERS.get(resolvedCredentials.mechanism);
if (!provider) {
return callback(
new MongoMissingCredentialsError(
`Reauthenticate failed due to no auth provider for ${credentials.mechanism}`
)
);
}
provider.reauth(authContext, error => {
if (error) {
return callback(error);
}
return fn(undefined, connection, (fnErr, fnResult) => {
if (fnErr) {
return callback(fnErr);
}
callback(undefined, fnResult);
});
});
}

/** Clear the min pool size timer */
private clearMinPoolSizeTimer(): void {
const minPoolSizeTimer = this[kMinPoolSizeTimer];
Expand Down
3 changes: 2 additions & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export const MONGODB_ERROR_CODES = Object.freeze({
IllegalOperation: 20,
MaxTimeMSExpired: 50,
UnknownReplWriteConcern: 79,
UnsatisfiableWriteConcern: 100
UnsatisfiableWriteConcern: 100,
Reauthenticate: 391
} as const);

// From spec@https://github.com/mongodb/specifications/blob/f93d78191f3db2898a59013a7ed5650352ef6da8/source/change-streams/change-streams.rst#resumable-error
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export type {
ResumeToken,
UpdateDescription
} from './change_stream';
export { AuthContext, AuthContextOptions } from './cmap/auth/auth_provider';
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
export type {
AuthMechanismProperties,
MongoCredentials,
Expand Down