@@ -5,6 +5,7 @@ import type {
55 OIDCCallbackContext ,
66 IdPServerInfo ,
77 OIDCRequestFunction ,
8+ OpenBrowserOptions ,
89} from './' ;
910import { createMongoDBOIDCPlugin , hookLoggerToMongoLogWriter } from './' ;
1011import { once } from 'events' ;
@@ -32,6 +33,24 @@ import { publicPluginToInternalPluginMap_DoNotUseOutsideOfTests } from './api';
3233import type { Server as HTTPServer } from 'http' ;
3334import { createServer as createHTTPServer } from 'http' ;
3435import type { AddressInfo } from 'net' ;
36+ import type {
37+ OIDCMockProviderConfig ,
38+ TokenMetadata ,
39+ } from '@mongodb-js/oidc-mock-provider' ;
40+ import { OIDCMockProvider } from '@mongodb-js/oidc-mock-provider' ;
41+
42+ // node-fetch@3 is ESM-only...
43+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
44+ const fetch : typeof import ( 'node-fetch' ) . default = ( ...args ) =>
45+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
46+ eval ( "import('node-fetch')" ) . then ( ( fetch : typeof import ( 'node-fetch' ) ) =>
47+ fetch . default ( ...args )
48+ ) ;
49+
50+ // A 'browser' implementation that just does HTTP requests and ignores the response.
51+ async function fetchBrowser ( { url } : OpenBrowserOptions ) : Promise < void > {
52+ ( await fetch ( url ) ) . body ?. resume ( ) ;
53+ }
3554
3655// Shorthand to avoid having to specify `principalName` and `abortSignal`
3756// if they aren't being used in the first place.
@@ -308,6 +327,7 @@ describe('OIDC plugin (local OIDC provider)', function () {
308327 expect ( serializedData . oidcPluginStateVersion ) . to . equal ( 0 ) ;
309328 expect ( serializedData . state ) . to . have . lengthOf ( 1 ) ;
310329 expect ( serializedData . state [ 0 ] [ 0 ] ) . to . be . a ( 'string' ) ;
330+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
311331 expect ( Object . keys ( serializedData . state [ 0 ] [ 1 ] ) . sort ( ) ) . to . deep . equal ( [
312332 'currentTokenSet' ,
313333 'lastIdTokenClaims' ,
@@ -827,6 +847,20 @@ describe('OIDC plugin (local OIDC provider)', function () {
827847 }
828848 } ) ;
829849
850+ it ( 'includes a helpful error message when attempting to reach out to invalid issuer' , async function ( ) {
851+ try {
852+ await requestToken ( plugin , {
853+ clientId : 'clientId' ,
854+ issuer : 'https://doesnotexist.mongodb.com/' ,
855+ } ) ;
856+ expect . fail ( 'missed exception' ) ;
857+ } catch ( err : any ) {
858+ expect ( err . message ) . to . include (
859+ 'Unable to fetch issuer metadata for "https://doesnotexist.mongodb.com/":'
860+ ) ;
861+ }
862+ } ) ;
863+
830864 context ( 'with an issuer that reports custom metadata' , function ( ) {
831865 let server : HTTPServer ;
832866 let response : Record < string , unknown > ;
@@ -1014,3 +1048,137 @@ describe('OIDC plugin (local OIDC provider)', function () {
10141048 } ) ;
10151049 } ) ;
10161050} ) ;
1051+
1052+ // eslint-disable-next-line mocha/max-top-level-suites
1053+ describe ( 'OIDC plugin (mock OIDC provider)' , function ( ) {
1054+ let provider : OIDCMockProvider ;
1055+ let getTokenPayload : OIDCMockProviderConfig [ 'getTokenPayload' ] ;
1056+ let additionalIssuerMetadata : OIDCMockProviderConfig [ 'additionalIssuerMetadata' ] ;
1057+ let receivedHttpRequests : string [ ] = [ ] ;
1058+ const tokenPayload = {
1059+ expires_in : 3600 ,
1060+ payload : {
1061+ // Define the user information stored inside the access tokens
1062+ groups : [ 'testgroup' ] ,
1063+ sub : 'testuser' ,
1064+ aud : 'resource-server-audience-value' ,
1065+ } ,
1066+ } ;
1067+
1068+ before ( async function ( ) {
1069+ if ( + process . version . slice ( 1 ) . split ( '.' ) [ 0 ] < 16 ) {
1070+ // JWK support for Node.js KeyObject.export() is only Node.js 16+
1071+ // but the OIDCMockProvider implementation needs it.
1072+ return this . skip ( ) ;
1073+ }
1074+ provider = await OIDCMockProvider . create ( {
1075+ getTokenPayload ( metadata : TokenMetadata ) {
1076+ return getTokenPayload ( metadata ) ;
1077+ } ,
1078+ additionalIssuerMetadata ( ) {
1079+ return additionalIssuerMetadata ?.( ) ?? { } ;
1080+ } ,
1081+ overrideRequestHandler ( url : string ) {
1082+ receivedHttpRequests . push ( url ) ;
1083+ } ,
1084+ } ) ;
1085+ } ) ;
1086+
1087+ after ( async function ( ) {
1088+ await provider ?. close ?.( ) ;
1089+ } ) ;
1090+
1091+ beforeEach ( function ( ) {
1092+ receivedHttpRequests = [ ] ;
1093+ getTokenPayload = ( ) => tokenPayload ;
1094+ additionalIssuerMetadata = undefined ;
1095+ } ) ;
1096+
1097+ context ( 'with different supported built-in scopes' , function ( ) {
1098+ let getScopes : ( ) => Promise < string [ ] > ;
1099+
1100+ beforeEach ( function ( ) {
1101+ getScopes = async function ( ) {
1102+ const plugin = createMongoDBOIDCPlugin ( {
1103+ openBrowserTimeout : 60_000 ,
1104+ openBrowser : fetchBrowser ,
1105+ allowedFlows : [ 'auth-code' ] ,
1106+ redirectURI : 'http://localhost:0/callback' ,
1107+ } ) ;
1108+ const result = await requestToken ( plugin , {
1109+ issuer : provider . issuer ,
1110+ clientId : 'mockclientid' ,
1111+ requestScopes : [ ] ,
1112+ } ) ;
1113+ const accessTokenContents = getJWTContents ( result . accessToken ) ;
1114+ return String ( accessTokenContents . scope ) . split ( ' ' ) . sort ( ) ;
1115+ } ;
1116+ } ) ;
1117+
1118+ it ( 'will get a list of built-in OpenID scopes by default' , async function ( ) {
1119+ additionalIssuerMetadata = undefined ;
1120+ expect ( await getScopes ( ) ) . to . deep . equal ( [ 'offline_access' , 'openid' ] ) ;
1121+ } ) ;
1122+
1123+ it ( 'will omit built-in scopes if the IdP does not announce support for them' , async function ( ) {
1124+ additionalIssuerMetadata = ( ) => ( { scopes_supported : [ 'openid' ] } ) ;
1125+ expect ( await getScopes ( ) ) . to . deep . equal ( [ 'openid' ] ) ;
1126+ } ) ;
1127+ } ) ;
1128+
1129+ context ( 'HTTP request tracking' , function ( ) {
1130+ it ( 'will log all outgoing HTTP requests' , async function ( ) {
1131+ const pluginHttpRequests : string [ ] = [ ] ;
1132+ const localServerHttpRequests : string [ ] = [ ] ;
1133+ const browserHttpRequests : string [ ] = [ ] ;
1134+
1135+ const plugin = createMongoDBOIDCPlugin ( {
1136+ openBrowserTimeout : 60_000 ,
1137+ openBrowser : async ( { url } ) => {
1138+ // eslint-disable-next-line no-constant-condition
1139+ while ( true ) {
1140+ browserHttpRequests . push ( url ) ;
1141+ const response = await fetch ( url , { redirect : 'manual' } ) ;
1142+ response . body ?. resume ( ) ;
1143+ const redirectTarget =
1144+ response . status >= 300 &&
1145+ response . status < 400 &&
1146+ response . headers . get ( 'location' ) ;
1147+ if ( redirectTarget )
1148+ url = new URL ( redirectTarget , response . url ) . href ;
1149+ else break ;
1150+ }
1151+ } ,
1152+ allowedFlows : [ 'auth-code' ] ,
1153+ redirectURI : 'http://localhost:0/callback' ,
1154+ } ) ;
1155+ plugin . logger . on ( 'mongodb-oidc-plugin:outbound-http-request' , ( ev ) =>
1156+ pluginHttpRequests . push ( ev . url )
1157+ ) ;
1158+ plugin . logger . on ( 'mongodb-oidc-plugin:inbound-http-request' , ( ev ) =>
1159+ localServerHttpRequests . push ( ev . url )
1160+ ) ;
1161+ await requestToken ( plugin , {
1162+ issuer : provider . issuer ,
1163+ clientId : 'mockclientid' ,
1164+ requestScopes : [ ] ,
1165+ } ) ;
1166+
1167+ const removeSearchParams = ( str : string ) =>
1168+ Object . assign ( new URL ( str ) , { search : '' } ) . toString ( ) ;
1169+ const allOutboundRequests = [
1170+ ...pluginHttpRequests ,
1171+ ...browserHttpRequests ,
1172+ ]
1173+ . map ( removeSearchParams )
1174+ . sort ( ) ;
1175+ const allInboundRequests = [
1176+ ...localServerHttpRequests ,
1177+ ...receivedHttpRequests ,
1178+ ]
1179+ . map ( removeSearchParams )
1180+ . sort ( ) ;
1181+ expect ( allOutboundRequests ) . to . deep . equal ( allInboundRequests ) ;
1182+ } ) ;
1183+ } ) ;
1184+ } ) ;
0 commit comments