• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mongodb-js / devtools-shared / 15214103202

23 May 2025 03:41PM UTC coverage: 72.383%. Remained the same
15214103202

Pull #541

github

lerouxb
make shell-api a peer dep
Pull Request #541: chore: make shell-api a peer dep

1478 of 2319 branches covered (63.73%)

Branch coverage included in aggregate %.

3203 of 4148 relevant lines covered (77.22%)

592.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

4.48
/packages/oidc-mock-provider/src/index.ts
1
import { once } from 'events';
1✔
2
import type {
3
  IncomingMessage,
4
  ServerResponse,
5
  Server as HTTPServer,
6
  RequestListener,
7
} from 'http';
8
import { createServer as createHTTPServer } from 'http';
1✔
9
import type { AddressInfo } from 'net';
10
import crypto from 'crypto';
1✔
11
import type { KeyPairKeyObjectResult } from 'crypto';
12
import { promisify } from 'util';
1✔
13
import { URLSearchParams } from 'url';
1✔
14

15
async function randomString(n: number, enc: BufferEncoding) {
16
  return (await promisify(crypto.randomBytes)(n)).toString(enc);
×
17
}
18
function toJSONtoUTF8toBase64Url(obj: unknown): string {
19
  return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url');
×
20
}
21

22
export interface TokenMetadata {
23
  // Using lower_snake_case here for some of the standard OIDC
24
  // parameters that are defined this way.
25
  client_id: string;
26
  scope: string;
27
  nonce?: string;
28
}
29

30
export type MaybePromise<T> = T | PromiseLike<T>;
31

32
export interface OIDCMockProviderConfig {
33
  /**
34
   * expires_in, payload: Return fields to be included in the generated Access and ID Tokens.
35
   * This should include e.g. `sub` and any other OIDC claims that are relevant.
36
   *
37
   * skipIdToken: Exclude ID Token
38
   *
39
   * customIdTokenPayload: Custom overrides in payload data for the ID token
40
   */
41
  getTokenPayload(metadata: TokenMetadata): MaybePromise<{
42
    expires_in: number;
43
    payload: Record<string, unknown>;
44
    customIdTokenPayload?: Record<string, unknown>;
45
    skipIdToken?: boolean;
46
  }>;
47

48
  /**
49
   * Allow override special handling for specific types of requests.
50
   */
51
  overrideRequestHandler?(
52
    url: string,
53
    req: IncomingMessage,
54
    res: ServerResponse,
55
  ): MaybePromise<void>;
56

57
  /**
58
   * Optional port number for the server to listen on.
59
   */
60
  port?: number;
61

62
  /**
63
   * Optional hostname for the server to listen on.
64
   */
65
  hostname?: string;
66

67
  /**
68
   * Optional bind to all IPv4 and IPv6 addresses.
69
   */
70
  bindIpAll?: boolean;
71

72
  /**
73
   * Optional additional fields to be returned when the OIDC configuration is accessed.
74
   */
75
  additionalIssuerMetadata?: () => Record<string, unknown>;
76

77
  /**
78
   * Optional replacement for the HTTP 'createServer' function, e.g. to specify a HTTPS
79
   * server with TLS settings.
80
   */
81
  createHTTPServer?: (requestListener: RequestListener) => HTTPServer;
82
}
83

84
/**
85
 * A mock OIDC authorization server (AS) implementation.
86
 *
87
 * This mock will happily give out valid tokens with arbitrary contents, so
88
 * it is absolutely unusable for usage outside of testing!
89
 */
90
export class OIDCMockProvider {
1✔
91
  public httpServer: HTTPServer;
92
  public issuer: string; // URL of the HTTP server
93

94
  // This provider only supports a single RSA key currently.
95
  private kid: string;
96
  private keys: KeyPairKeyObjectResult;
97

98
  private state = new Map<string, unknown>();
×
99
  private config: OIDCMockProviderConfig;
100

101
  private constructor(config: OIDCMockProviderConfig) {
102
    this.httpServer = (config.createHTTPServer ?? createHTTPServer)(
×
103
      (req, res) => void this.handleRequest(req, res),
×
104
    );
105
    this.config = config;
×
106

107
    // Initialized in .init().
108
    this.issuer = '';
×
109
    this.kid = '';
×
110
    this.keys = {} as unknown as typeof this.keys;
×
111
  }
112

113
  private async init(): Promise<this> {
114
    this.httpServer.listen(
×
115
      this.config.port ?? 0,
×
116
      this.config.bindIpAll ? '::' : this.config.hostname,
×
117
    );
118
    await once(this.httpServer, 'listening');
×
119
    const { port } = this.httpServer.address() as AddressInfo;
×
120
    this.issuer = `${
×
121
      'setSecureContext' in this.httpServer ? 'https' : 'http'
×
122
    }://${this.config.hostname ?? 'localhost'}:${port}`;
×
123
    this.kid = await randomString(8, 'hex');
×
124
    this.keys = await promisify(crypto.generateKeyPair)('rsa', {
×
125
      modulusLength: 2048,
126
    });
127
    return this;
×
128
  }
129

130
  public static async create(
131
    config: OIDCMockProviderConfig,
132
  ): Promise<OIDCMockProvider> {
133
    return await new this(config).init();
×
134
  }
135

136
  public async close(): Promise<void> {
137
    this.httpServer.close();
×
138
    await once(this.httpServer, 'close');
×
139
  }
140

141
  private async handleRequest(
142
    req: IncomingMessage,
143
    res: ServerResponse,
144
  ): Promise<void> {
145
    res.setHeader('Content-Type', 'application/json');
×
146
    try {
×
147
      const url = new URL(req.url || '/', this.issuer);
×
148
      if (req.method === 'POST') {
×
149
        // For simplicity, just merge POST parameters with GET parameters...
150
        if (
×
151
          req.headers['content-type'] !== 'application/x-www-form-urlencoded'
152
        ) {
153
          throw new Error(
×
154
            'Only accepting application/x-www-form-urlencoded POST bodies',
155
          );
156
        }
157
        let body = '';
×
158
        for await (const chunk of req.setEncoding('utf8')) {
×
159
          body += chunk;
×
160
        }
161
        const parsed = new URLSearchParams(body);
×
162
        url.search = new URLSearchParams({
×
163
          ...Object.fromEntries(url.searchParams),
164
          ...Object.fromEntries(parsed),
165
        }).toString();
166
      }
167
      let result: unknown;
168
      await this.config.overrideRequestHandler?.(url.toString(), req, res);
×
169
      if (res.writableEnded) return;
×
170
      switch (url.pathname) {
×
171
        case '/.well-known/openid-configuration':
172
          result = {
×
173
            issuer: this.issuer,
174
            token_endpoint: new URL('/token', this.issuer).toString(),
175
            authorization_endpoint: new URL(
176
              '/authorize',
177
              this.issuer,
178
            ).toString(),
179
            jwks_uri: new URL('/jwks', this.issuer).toString(),
180
            device_authorization_endpoint: new URL(
181
              '/device',
182
              this.issuer,
183
            ).toString(),
184
            ...this.config.additionalIssuerMetadata?.(),
185
          };
186
          break;
×
187
        case '/jwks':
188
          // Provide this server's public key in JWK format
189
          result = {
×
190
            keys: [
191
              {
192
                alg: 'RS256',
193
                kid: this.kid,
194
                ...this.keys.publicKey.export({ format: 'jwk' }),
195
              },
196
            ],
197
          };
198
          break;
×
199
        case '/authorize': {
200
          // Authorization code flow entry point, immediately redirect to client with success
201
          const {
202
            client_id,
203
            scope,
204
            response_type,
205
            redirect_uri,
206
            code_challenge,
207
            code_challenge_method,
208
            state,
209
            nonce,
210
          } = Object.fromEntries(url.searchParams);
×
211
          if (response_type !== 'code') {
×
212
            throw new Error(`unknown response_type ${response_type}`);
×
213
          }
214
          const redirectTo = new URL(redirect_uri);
×
215
          redirectTo.search = new URLSearchParams({
×
216
            code: await this.storeForSingleRetrieval({
217
              client_id,
218
              scope,
219
              code_challenge,
220
              code_challenge_method,
221
              nonce,
222
            }),
223
            state,
224
          }).toString();
225
          res.statusCode = 307;
×
226
          res.setHeader('Location', redirectTo.toString());
×
227
          res.end(`Moved to ${redirectTo.toString()}`);
×
228
          break;
×
229
        }
230
        case '/token': {
231
          // Provide a token after successful Auth Code Flow/Device Auth Flow
232
          const { code, code_verifier, device_code } = Object.fromEntries(
×
233
            url.searchParams,
234
          );
235
          const {
236
            client_id,
237
            scope,
238
            code_challenge,
239
            code_challenge_method,
240
            isDeviceCode,
241
            nonce,
242
          } = this.retrieveFromStorage(device_code ?? code);
×
243

244
          if (!isDeviceCode) {
×
245
            // Verify the code challenge. Not strictly necessary here since
246
            // we assume the OIDC implementation we are using to be correct
247
            // and this server is test-only, but also not a lot of extra work
248
            // to add this.
249
            if (code_challenge_method !== 'S256') {
×
250
              throw new Error(
×
251
                `Unsupported code challenge method ${String(
252
                  code_challenge_method,
253
                )}`,
254
              );
255
            }
256

257
            const expectedChallenge = crypto
×
258
              .createHash('sha256')
259
              .update(code_verifier)
260
              .digest('hex');
261
            const actualChallenge = Buffer.from(
×
262
              // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
263
              code_challenge,
264
              'base64',
265
            ).toString('hex');
266
            if (expectedChallenge !== actualChallenge) {
×
267
              throw new Error('Challenge mismatch');
×
268
            }
269
          }
270

271
          const { access_token, id_token, expires_in } = await this.issueToken({
×
272
            client_id,
273
            scope,
274
            nonce,
275
          });
276

277
          // Issue a token response:
278
          result = {
×
279
            access_token: access_token,
280
            id_token: id_token,
281
            refresh_token: await this.storeForSingleRetrieval({
282
              id_token,
283
              access_token,
284
            }),
285
            token_type: 'Bearer',
286
            expires_in,
287
          };
288
          break;
×
289
        }
290
        case '/device': {
291
          const { client_id, scope } = Object.fromEntries(url.searchParams);
×
292
          const device_code = await this.storeForSingleRetrieval({
×
293
            client_id,
294
            scope,
295
            isDeviceCode: true,
296
          });
297
          result = {
×
298
            device_code,
299
            user_code: await randomString(8, 'hex'),
300
            verification_uri: new URL('/verify-device', this.issuer).toString(),
301
            expires_in: 3600,
302
            interval: 1,
303
          };
304
          break;
×
305
        }
306
        case '/verify-device':
307
          // The Device Auth Flow requires a URL to point users at.
308
          // We can just use this as a dummy endpoint.
309
          res.statusCode = 200;
×
310
          result = { status: 'Verified!' };
×
311
          break;
×
312
        default:
313
          res.statusCode = 404;
×
314
          result = { error: 'not found:' + url.pathname };
×
315
          break;
×
316
      }
317
      res.end(JSON.stringify(result));
×
318
    } catch (err) {
319
      res.statusCode = 500;
×
320
      res.end(
×
321
        JSON.stringify({
322
          error:
323
            typeof err === 'object' && err && 'message' in err
×
324
              ? (err as Error).message
325
              : String(err),
326
        }),
327
      );
328
    }
329
  }
330

331
  private async issueToken(metadata: TokenMetadata): Promise<{
332
    expires_in: number;
333
    access_token: string;
334
    id_token: string | undefined;
335
  }> {
336
    const { expires_in, payload, skipIdToken, customIdTokenPayload } =
337
      await this.config.getTokenPayload(metadata);
×
338
    const currentTimeInSeconds = Math.floor(Date.now() / 1000);
×
339
    const header = {
×
340
      alg: 'RS256',
341
      typ: 'JWT',
342
      kid: this.kid,
343
    };
344
    const fullPayload = {
×
345
      jti: await randomString(8, 'hex'),
346
      iat: currentTimeInSeconds,
347
      exp: currentTimeInSeconds + expires_in,
348
      client_id: metadata.client_id,
349
      scope: metadata.scope,
350
      iss: this.issuer,
351
      aud: metadata.client_id,
352
      nonce: metadata.nonce,
353
      ...payload,
354
    };
355
    const makeToken = (payload: Record<string, unknown>) => {
×
356
      const signedMaterial =
357
        toJSONtoUTF8toBase64Url(header) +
×
358
        '.' +
359
        toJSONtoUTF8toBase64Url(payload);
360
      const signature = crypto
×
361
        .createSign('RSA-SHA256')
362
        .update(signedMaterial)
363
        .sign(this.keys.privateKey, 'base64url');
364
      return `${signedMaterial}.${signature}`;
×
365
    };
366
    return {
×
367
      expires_in,
368
      access_token: makeToken(fullPayload),
369
      // In an ID Token, aud === client_id, in an Access Token, not necessarily
370
      id_token: skipIdToken
×
371
        ? undefined
372
        : makeToken({
373
            ...fullPayload,
374
            aud: metadata.client_id,
375
            ...customIdTokenPayload,
376
          }),
377
    };
378
  }
379

380
  // Store a value for later re-use in another HTTP request/response pair.
381
  private async storeForSingleRetrieval(payload: unknown): Promise<string> {
382
    const id = await randomString(12, 'base64');
×
383
    this.state.set(id, payload);
×
384
    return id;
×
385
  }
386

387
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
388
  private retrieveFromStorage(id: string): any {
389
    const entry = this.state.get(id);
×
390
    this.state.delete(id);
×
391
    return entry;
×
392
  }
393
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc