@@ -5,6 +5,7 @@ import type {
5
5
OIDCCallbackContext ,
6
6
IdPServerInfo ,
7
7
OIDCRequestFunction ,
8
+ OpenBrowserOptions ,
8
9
} from './' ;
9
10
import { createMongoDBOIDCPlugin , hookLoggerToMongoLogWriter } from './' ;
10
11
import { once } from 'events' ;
@@ -32,6 +33,24 @@ import { publicPluginToInternalPluginMap_DoNotUseOutsideOfTests } from './api';
32
33
import type { Server as HTTPServer } from 'http' ;
33
34
import { createServer as createHTTPServer } from 'http' ;
34
35
import 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
+ }
35
54
36
55
// Shorthand to avoid having to specify `principalName` and `abortSignal`
37
56
// if they aren't being used in the first place.
@@ -308,6 +327,7 @@ describe('OIDC plugin (local OIDC provider)', function () {
308
327
expect ( serializedData . oidcPluginStateVersion ) . to . equal ( 0 ) ;
309
328
expect ( serializedData . state ) . to . have . lengthOf ( 1 ) ;
310
329
expect ( serializedData . state [ 0 ] [ 0 ] ) . to . be . a ( 'string' ) ;
330
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
311
331
expect ( Object . keys ( serializedData . state [ 0 ] [ 1 ] ) . sort ( ) ) . to . deep . equal ( [
312
332
'currentTokenSet' ,
313
333
'lastIdTokenClaims' ,
@@ -827,6 +847,20 @@ describe('OIDC plugin (local OIDC provider)', function () {
827
847
}
828
848
} ) ;
829
849
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
+
830
864
context ( 'with an issuer that reports custom metadata' , function ( ) {
831
865
let server : HTTPServer ;
832
866
let response : Record < string , unknown > ;
@@ -1014,3 +1048,137 @@ describe('OIDC plugin (local OIDC provider)', function () {
1014
1048
} ) ;
1015
1049
} ) ;
1016
1050
} ) ;
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