Skip to content

Commit

Permalink
feat(NODE-5034): support OIDC auth options (#3557)
Browse files Browse the repository at this point in the history
  • Loading branch information
durran committed Feb 8, 2023
1 parent e8a30b1 commit 20a4fec
Show file tree
Hide file tree
Showing 10 changed files with 456 additions and 31 deletions.
59 changes: 57 additions & 2 deletions src/cmap/auth/mongo_credentials.ts
@@ -1,7 +1,12 @@
// Resolves the default auth mechanism according to
import type { Document } from '../../bson';
import { MongoAPIError, MongoMissingCredentialsError } from '../../error';
import {
MongoAPIError,
MongoInvalidArgumentError,
MongoMissingCredentialsError
} from '../../error';
import { GSSAPICanonicalizationValue } from './gssapi';
import type { OIDCRefreshFunction, OIDCRequestFunction } from './mongodb_oidc';
import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism } from './providers';

// https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst
Expand All @@ -25,13 +30,25 @@ function getDefaultAuthMechanism(hello?: Document): AuthMechanism {
return AuthMechanism.MONGODB_CR;
}

/** @public */
/**
* TODO: NODE-5035: Make OIDC properties public.
*
* @public
* */
export interface AuthMechanismProperties extends Document {
SERVICE_HOST?: string;
SERVICE_NAME?: string;
SERVICE_REALM?: string;
CANONICALIZE_HOST_NAME?: GSSAPICanonicalizationValue;
AWS_SESSION_TOKEN?: string;
/** @internal Name for the OIDC device workflow */
DEVICE_NAME?: 'aws' | 'azure' | 'gcp';
/** @internal Similar to a username, is require by OIDC when more than one IDP is configured. */
PRINCIPAL_NAME?: string;
/** @internal User provided callback to get OIDC auth credentials */
REQUEST_TOKEN_CALLBACK?: OIDCRequestFunction;
/** @internal User provided callback to refresh OIDC auth credentials */
REFRESH_TOKEN_CALLBACK?: OIDCRefreshFunction;
}

/** @public */
Expand Down Expand Up @@ -137,6 +154,44 @@ export class MongoCredentials {
throw new MongoMissingCredentialsError(`Username required for mechanism '${this.mechanism}'`);
}

if (this.mechanism === AuthMechanism.MONGODB_OIDC) {
if (this.username) {
throw new MongoInvalidArgumentError(
`Username not permitted for mechanism '${this.mechanism}'. Use PRINCIPAL_NAME instead.`
);
}

if (this.mechanismProperties.PRINCIPAL_NAME && this.mechanismProperties.DEVICE_NAME) {
throw new MongoInvalidArgumentError(
`PRINCIPAL_NAME and DEVICE_NAME may not be used together for mechanism '${this.mechanism}'.`
);
}

if (this.mechanismProperties.DEVICE_NAME && this.mechanismProperties.DEVICE_NAME !== 'aws') {
throw new MongoInvalidArgumentError(
`Currently only a DEVICE_NAME of 'aws' is supported for mechanism '${this.mechanism}'.`
);
}

if (
this.mechanismProperties.REFRESH_TOKEN_CALLBACK &&
!this.mechanismProperties.REQUEST_TOKEN_CALLBACK
) {
throw new MongoInvalidArgumentError(
`A REQUEST_TOKEN_CALLBACK must be provided when using a REFRESH_TOKEN_CALLBACK for mechanism '${this.mechanism}'`
);
}

if (
!this.mechanismProperties.DEVICE_NAME &&
!this.mechanismProperties.REQUEST_TOKEN_CALLBACK
) {
throw new MongoInvalidArgumentError(
`Either a DEVICE_NAME or a REQUEST_TOKEN_CALLBACK must be specified for mechanism '${this.mechanism}'.`
);
}
}

if (AUTH_MECHS_AUTH_SRC_EXTERNAL.has(this.mechanism)) {
if (this.source != null && this.source !== '$external') {
// TODO(NODE-3485): Replace this with a MongoAuthValidationError
Expand Down
39 changes: 39 additions & 0 deletions src/cmap/auth/mongodb_oidc.ts
@@ -0,0 +1,39 @@
/**
* TODO: NODE-5035: Make API public
*
* @internal */
export interface OIDCMechanismServerStep1 {
authorizeEndpoint?: string;
tokenEndpoint?: string;
deviceAuthorizeEndpoint?: string;
clientId: string;
clientSecret?: string;
requestScopes?: string[];
}

/**
* TODO: NODE-5035: Make API public
*
* @internal */
export interface OIDCRequestTokenResult {
accessToken: string;
expiresInSeconds?: number;
refreshToken?: string;
}

/**
* TODO: NODE-5035: Make API public
*
* @internal */
export type OIDCRequestFunction = (
idl: OIDCMechanismServerStep1
) => Promise<OIDCRequestTokenResult>;

/**
* TODO: NODE-5035: Make API public
*
* @internal */
export type OIDCRefreshFunction = (
idl: OIDCMechanismServerStep1,
result: OIDCRequestTokenResult
) => Promise<OIDCRequestTokenResult>;
5 changes: 4 additions & 1 deletion src/cmap/auth/providers.ts
Expand Up @@ -7,7 +7,9 @@ export const AuthMechanism = Object.freeze({
MONGODB_PLAIN: 'PLAIN',
MONGODB_SCRAM_SHA1: 'SCRAM-SHA-1',
MONGODB_SCRAM_SHA256: 'SCRAM-SHA-256',
MONGODB_X509: 'MONGODB-X509'
MONGODB_X509: 'MONGODB-X509',
/** @internal TODO: NODE-5035: Make mechanism public. */
MONGODB_OIDC: 'MONGODB-OIDC'
} as const);

/** @public */
Expand All @@ -17,5 +19,6 @@ export type AuthMechanism = typeof AuthMechanism[keyof typeof AuthMechanism];
export const AUTH_MECHS_AUTH_SRC_EXTERNAL = new Set<AuthMechanism>([
AuthMechanism.MONGODB_GSSAPI,
AuthMechanism.MONGODB_AWS,
AuthMechanism.MONGODB_OIDC,
AuthMechanism.MONGODB_X509
]);
46 changes: 28 additions & 18 deletions src/connection_string.ts
Expand Up @@ -386,6 +386,7 @@ export function parseOptions(
const isGssapi = mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_GSSAPI;
const isX509 = mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_X509;
const isAws = mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_AWS;
const isOidc = mongoOptions.credentials.mechanism === AuthMechanism.MONGODB_OIDC;
if (
(isGssapi || isX509) &&
allOptions.has('authSource') &&
Expand All @@ -397,7 +398,11 @@ export function parseOptions(
);
}

if (!(isGssapi || isX509 || isAws) && mongoOptions.dbName && !allOptions.has('authSource')) {
if (
!(isGssapi || isX509 || isAws || isOidc) &&
mongoOptions.dbName &&
!allOptions.has('authSource')
) {
// inherit the dbName unless GSSAPI or X509, then silently ignore dbName
// and there was no specific authSource given
mongoOptions.credentials = MongoCredentials.merge(mongoOptions.credentials, {
Expand Down Expand Up @@ -678,26 +683,31 @@ export const OPTIONS = {
},
authMechanismProperties: {
target: 'credentials',
transform({ options, values: [optionValue] }): MongoCredentials {
if (typeof optionValue === 'string') {
const mechanismProperties = Object.create(null);

for (const [key, value] of entriesFromString(optionValue)) {
try {
mechanismProperties[key] = getBoolean(key, value);
} catch {
mechanismProperties[key] = value;
transform({ options, values }): MongoCredentials {
// We can have a combination of options passed in the URI and options passed
// as an object to the MongoClient. So we must transform the string options
// as well as merge them together with a potentially provided object.
let mechanismProperties = Object.create(null);

for (const optionValue of values) {
if (typeof optionValue === 'string') {
for (const [key, value] of entriesFromString(optionValue)) {
try {
mechanismProperties[key] = getBoolean(key, value);
} catch {
mechanismProperties[key] = value;
}
}
} else {
if (!isRecord(optionValue)) {
throw new MongoParseError('AuthMechanismProperties must be an object');
}
mechanismProperties = { ...optionValue };
}

return MongoCredentials.merge(options.credentials, {
mechanismProperties
});
}
if (!isRecord(optionValue)) {
throw new MongoParseError('AuthMechanismProperties must be an object');
}
return MongoCredentials.merge(options.credentials, { mechanismProperties: optionValue });
return MongoCredentials.merge(options.credentials, {
mechanismProperties
});
}
},
authSource: {
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Expand Up @@ -202,6 +202,12 @@ export type {
MongoCredentials,
MongoCredentialsOptions
} from './cmap/auth/mongo_credentials';
export type {
OIDCMechanismServerStep1,
OIDCRefreshFunction,
OIDCRequestFunction,
OIDCRequestTokenResult
} from './cmap/auth/mongodb_oidc';
export type {
BinMsg,
MessageHeader,
Expand Down
Expand Up @@ -480,6 +480,156 @@
"AWS_SESSION_TOKEN": "token!@#$%^&*()_+"
}
}
},
{
"description": "should recognise the mechanism and request callback (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC",
"callback": [
"oidcRequest"
],
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"REQUEST_TOKEN_CALLBACK": true
}
}
},
{
"description": "should recognise the mechanism when auth source is explicitly specified and with request callback (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authSource=$external",
"callback": [
"oidcRequest"
],
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"REQUEST_TOKEN_CALLBACK": true
}
}
},
{
"description": "should recognise the mechanism with request and refresh callback (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authSource=$external",
"callback": [
"oidcRequest",
"oidcRefresh"
],
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"REQUEST_TOKEN_CALLBACK": true,
"REFRESH_TOKEN_CALLBACK": true
}
}
},
{
"description": "should recognise the mechanism and principalName with request callback (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PRINCIPAL_NAME:principalName",
"callback": [
"oidcRequest"
],
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"REQUEST_TOKEN_CALLBACK": true,
"PRINCIPAL_NAME": "principalName"
}
}
},
{
"description": "should recognise the mechanism with aws device (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=DEVICE_NAME:aws",
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"DEVICE_NAME": "aws"
}
}
},
{
"description": "should recognise the mechanism when auth source is explicitly specified and with aws device (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authSource=$external&authMechanismProperties=DEVICE_NAME:aws",
"valid": true,
"credential": {
"username": null,
"password": null,
"source": "$external",
"mechanism": "MONGODB-OIDC",
"mechanism_properties": {
"DEVICE_NAME": "aws"
}
}
},
{
"description": "should throw an exception if username is specified (MONGODB-OIDC)",
"uri": "mongodb://user@localhost/?authMechanism=MONGODB-OIDC",
"callback": [
"oidcRequest"
],
"valid": false,
"credential": null
},
{
"description": "should throw an exception if username and password are specified (MONGODB-OIDC)",
"uri": "mongodb://user:pass@localhost/?authMechanism=MONGODB-OIDC",
"callback": [
"oidcRequest"
],
"valid": false,
"credential": null
},
{
"description": "should throw an exception if principalName and deviceName are specified (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PRINCIPAL_NAME:principalName,DEVICE_NAME:aws",
"valid": false,
"credential": null
},
{
"description": "should throw an exception if specified deviceName is not supported (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=DEVICE_NAME:unexisted",
"valid": false,
"credential": null
},
{
"description": "should throw an exception if neither deviceName nor callbacks specified (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC",
"valid": false,
"credential": null
},
{
"description": "should throw an exception when only refresh callback is specified (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC",
"callback": [
"oidcRefresh"
],
"valid": false,
"credential": null
},
{
"description": "should throw an exception when unsupported auth property is specified (MONGODB-OIDC)",
"uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=UnsupportedProperty:unexisted",
"valid": false,
"credential": null
}
]
}
}

0 comments on commit 20a4fec

Please sign in to comment.