Skip to content

Commit

Permalink
feat(NODE-5191): OIDC Auth Updates (#3637)
Browse files Browse the repository at this point in the history
  • Loading branch information
durran committed May 4, 2023
1 parent ce8e69b commit c52a4ed
Show file tree
Hide file tree
Showing 23 changed files with 1,952 additions and 803 deletions.
2 changes: 1 addition & 1 deletion .evergreen/config.in.yml
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .evergreen/config.yml
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .evergreen/run-oidc-tests.sh
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -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",
Expand Down
37 changes: 35 additions & 2 deletions src/cmap/auth/mongo_credentials.ts
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)) {
Expand Down
98 changes: 61 additions & 37 deletions src/cmap/auth/mongodb_oidc.ts
@@ -1,57 +1,85 @@
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[];
}

/**
* @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<OIDCRequestTokenResult>;
info: IdPServerInfo,
context: OIDCCallbackContext
) => Promise<IdPServerResponse>;

/**
* @public
* @experimental
*/
export type OIDCRefreshFunction = (
principalName: string,
serverResult: OIDCMechanismServerStep1,
result: OIDCRequestTokenResult,
timeout: AbortSignal | number
) => Promise<OIDCRequestTokenResult>;
info: IdPServerInfo,
context: OIDCCallbackContext
) => Promise<IdPServerResponse>;

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<Document>;

/**
* Get the document to add for speculative authentication.
*/
speculativeAuth(credentials: MongoCredentials): Promise<Document>;
}

/** @internal */
export const OIDC_WORKFLOWS: Map<ProviderName, Workflow> = new Map();
OIDC_WORKFLOWS.set('callback', new CallbackWorkflow());
Expand All @@ -73,19 +101,10 @@ export class MongoDBOIDC extends AuthProvider {
* Authenticate using OIDC
*/
override async auth(authContext: AuthContext): Promise<void> {
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);
}

/**
Expand All @@ -95,19 +114,24 @@ export class MongoDBOIDC extends AuthProvider {
handshakeDoc: HandshakeDocument,
authContext: AuthContext
): Promise<HandshakeDocument> {
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.
*/
Expand Down
9 changes: 6 additions & 3 deletions 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.
*
Expand All @@ -19,8 +22,8 @@ export class AwsServiceWorkflow extends ServiceWorkflow {
async getToken(): Promise<string> {
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');
}
}
27 changes: 27 additions & 0 deletions src/cmap/auth/mongodb_oidc/cache.ts
@@ -0,0 +1,27 @@
/**
* Base class for OIDC caches.
*/
export abstract class Cache<T> {
entries: Map<string, T>;

/**
* Create a new cache.
*/
constructor() {
this.entries = new Map<string, T>();
}

/**
* 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]);
}
}

0 comments on commit c52a4ed

Please sign in to comment.