Skip to content

Commit a41846d

Browse files
authored
feat(NODE-5036): reauthenticate OIDC and retry (#3589)
1 parent 7649722 commit a41846d

21 files changed

+923
-62
lines changed

.evergreen/setup-oidc-roles.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ set -o xtrace # Write all commands first to stderr
55
cd ${DRIVERS_TOOLS}/.evergreen/auth_oidc
66
. ./activate-authoidcvenv.sh
77

8-
${DRIVERS_TOOLS}/mongodb/bin/mongosh setup_oidc.js
8+
${DRIVERS_TOOLS}/mongodb/bin/mongosh "mongodb://localhost:27017,localhost:27018/?replicaSet=oidc-repl0&readPreference=primary" setup_oidc.js

src/cmap/auth/auth_provider.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ import type { HandshakeDocument } from '../connect';
55
import type { Connection, ConnectionOptions } from '../connection';
66
import type { MongoCredentials } from './mongo_credentials';
77

8+
/** @internal */
89
export type AuthContextOptions = ConnectionOptions & ClientMetadataOptions;
910

10-
/** Context used during authentication */
11+
/**
12+
* Context used during authentication
13+
* @internal
14+
*/
1115
export class AuthContext {
1216
/** The connection to authenticate */
1317
connection: Connection;
1418
/** The credentials to use for authentication */
1519
credentials?: MongoCredentials;
20+
/** If the context is for reauthentication. */
21+
reauthenticating = false;
1622
/** The options passed to the `connect` method */
1723
options: AuthContextOptions;
1824

@@ -57,4 +63,22 @@ export class AuthProvider {
5763
// TODO(NODE-3483): Replace this with MongoMethodOverrideError
5864
callback(new MongoRuntimeError('`auth` method must be overridden by subclass'));
5965
}
66+
67+
/**
68+
* Reauthenticate.
69+
* @param context - The shared auth context.
70+
* @param callback - The callback.
71+
*/
72+
reauth(context: AuthContext, callback: Callback): void {
73+
// If we are already reauthenticating this is a no-op.
74+
if (context.reauthenticating) {
75+
return callback(new MongoRuntimeError('Reauthentication already in progress.'));
76+
}
77+
context.reauthenticating = true;
78+
const cb: Callback = (error, result) => {
79+
context.reauthenticating = false;
80+
callback(error, result);
81+
};
82+
this.auth(context, cb);
83+
}
6084
}

src/cmap/auth/mongo_credentials.ts

+3
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ export interface AuthMechanismProperties extends Document {
3737
SERVICE_REALM?: string;
3838
CANONICALIZE_HOST_NAME?: GSSAPICanonicalizationValue;
3939
AWS_SESSION_TOKEN?: string;
40+
/** @experimental */
4041
REQUEST_TOKEN_CALLBACK?: OIDCRequestFunction;
42+
/** @experimental */
4143
REFRESH_TOKEN_CALLBACK?: OIDCRefreshFunction;
44+
/** @experimental */
4245
PROVIDER_NAME?: 'aws';
4346
}
4447

src/cmap/auth/mongodb_oidc.ts

+19-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { AwsServiceWorkflow } from './mongodb_oidc/aws_service_workflow';
1111
import { CallbackWorkflow } from './mongodb_oidc/callback_workflow';
1212
import type { Workflow } from './mongodb_oidc/workflow';
1313

14-
/** @public */
14+
/**
15+
* @public
16+
* @experimental
17+
*/
1518
export interface OIDCMechanismServerStep1 {
1619
authorizationEndpoint?: string;
1720
tokenEndpoint?: string;
@@ -21,21 +24,30 @@ export interface OIDCMechanismServerStep1 {
2124
requestScopes?: string[];
2225
}
2326

24-
/** @public */
27+
/**
28+
* @public
29+
* @experimental
30+
*/
2531
export interface OIDCRequestTokenResult {
2632
accessToken: string;
2733
expiresInSeconds?: number;
2834
refreshToken?: string;
2935
}
3036

31-
/** @public */
37+
/**
38+
* @public
39+
* @experimental
40+
*/
3241
export type OIDCRequestFunction = (
3342
principalName: string,
3443
serverResult: OIDCMechanismServerStep1,
3544
timeout: AbortSignal | number
3645
) => Promise<OIDCRequestTokenResult>;
3746

38-
/** @public */
47+
/**
48+
* @public
49+
* @experimental
50+
*/
3951
export type OIDCRefreshFunction = (
4052
principalName: string,
4153
serverResult: OIDCMechanismServerStep1,
@@ -52,6 +64,7 @@ OIDC_WORKFLOWS.set('aws', new AwsServiceWorkflow());
5264

5365
/**
5466
* OIDC auth provider.
67+
* @experimental
5568
*/
5669
export class MongoDBOIDC extends AuthProvider {
5770
/**
@@ -65,7 +78,7 @@ export class MongoDBOIDC extends AuthProvider {
6578
* Authenticate using OIDC
6679
*/
6780
override auth(authContext: AuthContext, callback: Callback): void {
68-
const { connection, credentials, response } = authContext;
81+
const { connection, credentials, response, reauthenticating } = authContext;
6982

7083
if (response?.speculativeAuthenticate) {
7184
return callback();
@@ -86,7 +99,7 @@ export class MongoDBOIDC extends AuthProvider {
8699
)
87100
);
88101
}
89-
workflow.execute(connection, credentials).then(
102+
workflow.execute(connection, credentials, reauthenticating).then(
90103
result => {
91104
return callback(undefined, result);
92105
},

src/cmap/auth/mongodb_oidc/callback_workflow.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ export class CallbackWorkflow implements Workflow {
5858
* - put the new entry in the cache.
5959
* - execute step two.
6060
*/
61-
async execute(connection: Connection, credentials: MongoCredentials): Promise<Document> {
61+
async execute(
62+
connection: Connection,
63+
credentials: MongoCredentials,
64+
reauthenticate = false
65+
): Promise<Document> {
6266
const request = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK;
6367
const refresh = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK;
6468

@@ -69,8 +73,8 @@ export class CallbackWorkflow implements Workflow {
6973
refresh || null
7074
);
7175
if (entry) {
72-
// Check if the entry is not expired.
73-
if (entry.isValid()) {
76+
// Check if the entry is not expired and if we are reauthenticating.
77+
if (!reauthenticate && entry.isValid()) {
7478
// Skip step one and execute the step two saslContinue.
7579
try {
7680
const result = await finishAuth(entry.tokenResult, undefined, connection, credentials);

src/cmap/auth/mongodb_oidc/workflow.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export interface Workflow {
88
* All device workflows must implement this method in order to get the access
99
* token and then call authenticate with it.
1010
*/
11-
execute(connection: Connection, credentials: MongoCredentials): Promise<Document>;
11+
execute(
12+
connection: Connection,
13+
credentials: MongoCredentials,
14+
reauthenticate?: boolean
15+
): Promise<Document>;
1216

1317
/**
1418
* Get the document to add for speculative authentication.

src/cmap/auth/providers.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const AuthMechanism = Object.freeze({
88
MONGODB_SCRAM_SHA1: 'SCRAM-SHA-1',
99
MONGODB_SCRAM_SHA256: 'SCRAM-SHA-256',
1010
MONGODB_X509: 'MONGODB-X509',
11+
/** @experimental */
1112
MONGODB_OIDC: 'MONGODB-OIDC'
1213
} as const);
1314

src/cmap/auth/scram.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ class ScramSHA extends AuthProvider {
5353
}
5454

5555
override auth(authContext: AuthContext, callback: Callback) {
56-
const response = authContext.response;
57-
if (response && response.speculativeAuthenticate) {
56+
const { reauthenticating, response } = authContext;
57+
if (response?.speculativeAuthenticate && !reauthenticating) {
5858
continueScramConversation(
5959
this.cryptoMethod,
6060
response.speculativeAuthenticate,

src/cmap/connect.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
MIN_SUPPORTED_WIRE_VERSION
3737
} from './wire_protocol/constants';
3838

39-
const AUTH_PROVIDERS = new Map<AuthMechanism | string, AuthProvider>([
39+
/** @internal */
40+
export const AUTH_PROVIDERS = new Map<AuthMechanism | string, AuthProvider>([
4041
[AuthMechanism.MONGODB_AWS, new MongoDBAWS()],
4142
[AuthMechanism.MONGODB_CR, new MongoCR()],
4243
[AuthMechanism.MONGODB_GSSAPI, new GSSAPI()],
@@ -117,6 +118,7 @@ function performInitialHandshake(
117118
}
118119

119120
const authContext = new AuthContext(conn, credentials, options);
121+
conn.authContext = authContext;
120122
prepareHandshakeDocument(authContext, (err, handshakeDoc) => {
121123
if (err || !handshakeDoc) {
122124
return callback(err);

src/cmap/connection.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
uuidV4
3838
} from '../utils';
3939
import type { WriteConcern } from '../write_concern';
40+
import type { AuthContext } from './auth/auth_provider';
4041
import type { MongoCredentials } from './auth/mongo_credentials';
4142
import {
4243
CommandFailedEvent,
@@ -126,7 +127,6 @@ export interface ConnectionOptions
126127
noDelay?: boolean;
127128
socketTimeoutMS?: number;
128129
cancellationToken?: CancellationToken;
129-
130130
metadata: ClientMetadata;
131131
}
132132

@@ -164,6 +164,8 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
164164
cmd: Document,
165165
options: CommandOptions | undefined
166166
) => Promise<Document>;
167+
/** @internal */
168+
authContext?: AuthContext;
167169

168170
/**@internal */
169171
[kDelayedTimeoutId]: NodeJS.Timeout | null;

src/cmap/connection_pool.ts

+74-13
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@ import {
1616
CONNECTION_READY
1717
} from '../constants';
1818
import {
19+
AnyError,
20+
MONGODB_ERROR_CODES,
1921
MongoError,
2022
MongoInvalidArgumentError,
23+
MongoMissingCredentialsError,
2124
MongoNetworkError,
2225
MongoRuntimeError,
2326
MongoServerError
2427
} from '../error';
2528
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
2629
import type { Server } from '../sdam/server';
2730
import { Callback, eachAsync, List, makeCounter } from '../utils';
28-
import { connect } from './connect';
31+
import { AUTH_PROVIDERS, connect } from './connect';
2932
import { Connection, ConnectionEvents, ConnectionOptions } from './connection';
3033
import {
3134
ConnectionCheckedInEvent,
@@ -537,32 +540,30 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
537540
withConnection(
538541
conn: Connection | undefined,
539542
fn: WithConnectionCallback,
540-
callback?: Callback<Connection>
543+
callback: Callback<Connection>
541544
): void {
542545
if (conn) {
543546
// use the provided connection, and do _not_ check it in after execution
544547
fn(undefined, conn, (fnErr, result) => {
545-
if (typeof callback === 'function') {
546-
if (fnErr) {
547-
callback(fnErr);
548-
} else {
549-
callback(undefined, result);
550-
}
548+
if (fnErr) {
549+
return this.withReauthentication(fnErr, conn, fn, callback);
551550
}
551+
callback(undefined, result);
552552
});
553-
554553
return;
555554
}
556555

557556
this.checkOut((err, conn) => {
558557
// don't callback with `err` here, we might want to act upon it inside `fn`
559558
fn(err as MongoError, conn, (fnErr, result) => {
560-
if (typeof callback === 'function') {
561-
if (fnErr) {
562-
callback(fnErr);
559+
if (fnErr) {
560+
if (conn) {
561+
this.withReauthentication(fnErr, conn, fn, callback);
563562
} else {
564-
callback(undefined, result);
563+
callback(fnErr);
565564
}
565+
} else {
566+
callback(undefined, result);
566567
}
567568

568569
if (conn) {
@@ -572,6 +573,66 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
572573
});
573574
}
574575

576+
private withReauthentication(
577+
fnErr: AnyError,
578+
conn: Connection,
579+
fn: WithConnectionCallback,
580+
callback: Callback<Connection>
581+
) {
582+
if (fnErr instanceof MongoError && fnErr.code === MONGODB_ERROR_CODES.Reauthenticate) {
583+
this.reauthenticate(conn, fn, (error, res) => {
584+
if (error) {
585+
return callback(error);
586+
}
587+
callback(undefined, res);
588+
});
589+
} else {
590+
callback(fnErr);
591+
}
592+
}
593+
594+
/**
595+
* Reauthenticate on the same connection and then retry the operation.
596+
*/
597+
private reauthenticate(
598+
connection: Connection,
599+
fn: WithConnectionCallback,
600+
callback: Callback
601+
): void {
602+
const authContext = connection.authContext;
603+
if (!authContext) {
604+
return callback(new MongoRuntimeError('No auth context found on connection.'));
605+
}
606+
const credentials = authContext.credentials;
607+
if (!credentials) {
608+
return callback(
609+
new MongoMissingCredentialsError(
610+
'Connection is missing credentials when asked to reauthenticate'
611+
)
612+
);
613+
}
614+
const resolvedCredentials = credentials.resolveAuthMechanism(connection.hello || undefined);
615+
const provider = AUTH_PROVIDERS.get(resolvedCredentials.mechanism);
616+
if (!provider) {
617+
return callback(
618+
new MongoMissingCredentialsError(
619+
`Reauthenticate failed due to no auth provider for ${credentials.mechanism}`
620+
)
621+
);
622+
}
623+
provider.reauth(authContext, error => {
624+
if (error) {
625+
return callback(error);
626+
}
627+
return fn(undefined, connection, (fnErr, fnResult) => {
628+
if (fnErr) {
629+
return callback(fnErr);
630+
}
631+
callback(undefined, fnResult);
632+
});
633+
});
634+
}
635+
575636
/** Clear the min pool size timer */
576637
private clearMinPoolSizeTimer(): void {
577638
const minPoolSizeTimer = this[kMinPoolSizeTimer];

src/error.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export const MONGODB_ERROR_CODES = Object.freeze({
5858
IllegalOperation: 20,
5959
MaxTimeMSExpired: 50,
6060
UnknownReplWriteConcern: 79,
61-
UnsatisfiableWriteConcern: 100
61+
UnsatisfiableWriteConcern: 100,
62+
Reauthenticate: 391
6263
} as const);
6364

6465
// From spec@https://github.com/mongodb/specifications/blob/f93d78191f3db2898a59013a7ed5650352ef6da8/source/change-streams/change-streams.rst#resumable-error

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export type {
197197
ResumeToken,
198198
UpdateDescription
199199
} from './change_stream';
200+
export type { AuthContext, AuthContextOptions } from './cmap/auth/auth_provider';
200201
export type {
201202
AuthMechanismProperties,
202203
MongoCredentials,
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as path from 'path';
2+
3+
import { loadSpecTests } from '../../spec';
4+
import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner';
5+
6+
describe('Auth (unified)', function () {
7+
runUnifiedSuite(loadSpecTests(path.join('auth', 'unified')));
8+
});

0 commit comments

Comments
 (0)