imapkit 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +37 -21
  2. package/bin/help.txt +25 -1
  3. package/bin/imapkit.js +35 -0
  4. package/dist/browser/imapkit.js +101 -35
  5. package/dist/cjs/control.d.ts +16 -5
  6. package/dist/cjs/control.js +35 -4
  7. package/dist/cjs/oauth/http.d.ts +28 -0
  8. package/dist/cjs/oauth/http.js +216 -0
  9. package/dist/cjs/oauth/jwt.d.ts +33 -0
  10. package/dist/cjs/oauth/jwt.js +58 -0
  11. package/dist/cjs/oauth/pages.d.ts +47 -0
  12. package/dist/cjs/oauth/pages.js +317 -0
  13. package/dist/cjs/oauth/policy.d.ts +48 -0
  14. package/dist/cjs/oauth/policy.js +66 -0
  15. package/dist/cjs/oauth/provider.d.ts +455 -0
  16. package/dist/cjs/oauth/provider.js +1422 -0
  17. package/dist/cjs/plugins/oauthbearer.js +10 -15
  18. package/dist/cjs/plugins/xoauth2.js +4 -3
  19. package/dist/cjs/rest.d.ts +18 -1
  20. package/dist/cjs/rest.js +49 -31
  21. package/dist/cjs/server.d.ts +25 -1
  22. package/dist/cjs/server.js +73 -11
  23. package/dist/cjs/smtp-listener.d.ts +3 -2
  24. package/dist/cjs/smtp-listener.js +15 -3
  25. package/dist/cjs/types.d.ts +10 -0
  26. package/dist/esm/control.d.ts +16 -5
  27. package/dist/esm/control.js +35 -5
  28. package/dist/esm/oauth/http.d.ts +28 -0
  29. package/dist/esm/oauth/http.js +211 -0
  30. package/dist/esm/oauth/jwt.d.ts +33 -0
  31. package/dist/esm/oauth/jwt.js +50 -0
  32. package/dist/esm/oauth/pages.d.ts +47 -0
  33. package/dist/esm/oauth/pages.js +312 -0
  34. package/dist/esm/oauth/policy.d.ts +48 -0
  35. package/dist/esm/oauth/policy.js +59 -0
  36. package/dist/esm/oauth/provider.d.ts +455 -0
  37. package/dist/esm/oauth/provider.js +1415 -0
  38. package/dist/esm/plugins/oauthbearer.js +10 -15
  39. package/dist/esm/plugins/xoauth2.js +4 -3
  40. package/dist/esm/rest.d.ts +18 -1
  41. package/dist/esm/rest.js +48 -32
  42. package/dist/esm/server.d.ts +25 -1
  43. package/dist/esm/server.js +74 -12
  44. package/dist/esm/smtp-listener.d.ts +3 -2
  45. package/dist/esm/smtp-listener.js +15 -3
  46. package/dist/esm/types.d.ts +10 -0
  47. package/dist/plugin-help.json +2 -2
  48. package/package.json +6 -6
@@ -7,7 +7,7 @@ exports.parseClientResponse = parseClientResponse;
7
7
  * @help Works with and without SASL-IR [RFC4959]
8
8
  * @help Valid login info, same as for XOAUTH2:
9
9
  * @help Username (authzid, optional): testuser
10
- * @help Access Token: testtoken
10
+ * @help Access Token: testtoken, or a token of the OAuth provider
11
11
  */
12
12
  const KVSEP = '\x01';
13
13
  const utf8 = new TextDecoder('utf-8', { fatal: true });
@@ -93,21 +93,16 @@ function oauthbearerPlugin(server) {
93
93
  return auth === '' ? 'invalid_token' : 'invalid_request';
94
94
  }
95
95
  const token = bearer[1];
96
- let username = request.authzid;
97
- if (username === false) {
98
- // without an authzid the token tells who the user is
99
- username = Object.keys(server.users).find(name => {
100
- const user = server.users[name];
101
- return user && user.xoauth2 && user.xoauth2.accessToken === token;
102
- });
103
- }
104
- const user = server.getUser(username);
105
- if (!user || !user.xoauth2 || user.xoauth2.accessToken !== token) {
106
- return 'invalid_token';
107
- }
108
- request.username = username;
96
+ // without an authzid the token tells who the user is
97
+ const result = server.checkAccessToken(token, request.authzid === false ? undefined : request.authzid);
98
+ if ('error' in result) {
99
+ return result.error;
100
+ }
101
+ request.username = result.user;
109
102
  return false;
110
103
  };
104
+ // RFC 7628 section 3.2.2: with the OAuth provider the error result also says where to get a token and for which scope
105
+ const errorResult = (status) => server.oauth && server.oauth.issuer ? { status, scope: server.oauth.mailScope, 'openid-configuration': server.oauth.discoveryUrl() } : { status };
111
106
  // Decodes a base64 client response, sends BAD and returns false if it is not valid base64
112
107
  const decode = (connection, parsed, data, input) => {
113
108
  const decoded = connection.decodeSaslResponse(input);
@@ -159,7 +154,7 @@ function oauthbearerPlugin(server) {
159
154
  attributes: [
160
155
  {
161
156
  type: 'ATOM',
162
- value: Buffer.from(JSON.stringify({ status })).toString('base64')
157
+ value: Buffer.from(JSON.stringify(errorResult(status))).toString('base64')
163
158
  }
164
159
  ]
165
160
  }, 'AUTHENTICATE OAUTHBEARER CHALLENGE', parsed, data);
@@ -8,7 +8,7 @@ exports.default = xoauth2Plugin;
8
8
  * @help SASL-IR enabled and used while Gmail does not
9
9
  * @help Valid login info:
10
10
  * @help Username: testuser
11
- * @help Access Token: testtoken
11
+ * @help Access Token: testtoken, or a token of the OAuth provider
12
12
  */
13
13
  const utf8 = new TextDecoder('utf-8', { fatal: true });
14
14
  function xoauth2Plugin(server) {
@@ -94,7 +94,7 @@ function xoauth2Plugin(server) {
94
94
  }, 'AUTHENTICATE XOAUTH2 FAILED', parsed, data);
95
95
  return callback();
96
96
  }
97
- if (!userData.xoauth2 || userData.xoauth2.accessToken !== accessToken) {
97
+ if ('error' in server.checkAccessToken(accessToken, user)) {
98
98
  connection.send({
99
99
  tag: '+',
100
100
  attributes: [
@@ -103,7 +103,8 @@ function xoauth2Plugin(server) {
103
103
  value: Buffer.from(JSON.stringify({
104
104
  status: '400',
105
105
  schemes: 'Bearer',
106
- scope: 'https://mail.google.com/'
106
+ // the scope the OAuth provider of this server asks for, Gmail's without one
107
+ scope: server.oauth ? server.oauth.mailScope : 'https://mail.google.com/'
107
108
  })).toString('base64')
108
109
  }
109
110
  ]
@@ -41,6 +41,23 @@ declare function getRoutes(server: IMAPServer, streams?: Set<http.ServerResponse
41
41
  * @return {Object} OpenAPI document
42
42
  */
43
43
  declare function openApi(routes: Route[]): Record<string, unknown>;
44
+ /**
45
+ * Compares two strings in constant time, also when their lengths differ
46
+ *
47
+ * @param {String} a First
48
+ * @param {String} b Second
49
+ * @return {Boolean} true if equal
50
+ */
51
+ declare function safeEqual(a: string, b: string): boolean;
52
+ /**
53
+ * Reads a request body
54
+ *
55
+ * @param {Object} req HTTP request
56
+ * @param {Number} limit Largest body in octets
57
+ * @param {Function} tooBig Makes the error for a larger body
58
+ * @return {Promise<Buffer>} the body
59
+ */
60
+ declare function readRaw(req: http.IncomingMessage, limit: number, tooBig: () => Error): Promise<Buffer>;
44
61
  /**
45
62
  * Creates the HTTP server of the REST API, it does not listen yet
46
63
  *
@@ -50,5 +67,5 @@ declare function openApi(routes: Route[]): Record<string, unknown>;
50
67
  * @throws {Error} for a host that is not a loopback address without a token
51
68
  */
52
69
  declare function createRestServer(server: IMAPServer, options: RestOptions): RestServer;
53
- export { createRestServer, isLoopback, getRoutes, openApi };
70
+ export { createRestServer, isLoopback, safeEqual, readRaw, getRoutes, openApi };
54
71
  export type { RestOptions, RestServer };
package/dist/cjs/rest.js CHANGED
@@ -12,6 +12,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.createRestServer = createRestServer;
14
14
  exports.isLoopback = isLoopback;
15
+ exports.safeEqual = safeEqual;
16
+ exports.readRaw = readRaw;
15
17
  exports.getRoutes = getRoutes;
16
18
  exports.openApi = openApi;
17
19
  const node_http_1 = __importDefault(require("node:http"));
@@ -70,7 +72,7 @@ function decodeBody(value, encoding) {
70
72
  return encoding === 'base64' && typeof value === 'string' ? Buffer.from(value, 'base64') : value;
71
73
  }
72
74
  // the server events that GET /v1/events streams by default
73
- const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset'];
75
+ const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset', 'oauth'];
74
76
  // event types that are streamed only when ?types= asks for them, the protocol trace of every session is a lot of data
75
77
  const OPT_IN_EVENT_TYPES = ['trace'];
76
78
  const sessionNumber = (origin) => (origin ? origin.sessionNumber : null);
@@ -206,18 +208,18 @@ function getRoutes(server, streams = new Set()) {
206
208
  {
207
209
  method: 'POST',
208
210
  path: '/v1/users',
209
- summary: 'Adds a user ({ name, password, xoauth2 })',
211
+ summary: 'Adds a user ({ name, password, xoauth2, displayName, oauth })',
210
212
  handler: ({ body }) => {
211
- control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2 });
213
+ control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
212
214
  return { status: 201, body: { name: body.name } };
213
215
  }
214
216
  },
215
217
  {
216
218
  method: 'PUT',
217
219
  path: '/v1/users/{name}',
218
- summary: 'Changes the password or the XOAUTH2 token of a user',
220
+ summary: 'Changes the password, the XOAUTH2 token, the display name (displayName) or the OAuth settings ({ oauth: { consent } }) of a user',
219
221
  handler: ({ params, body }) => {
220
- control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2 });
222
+ control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
221
223
  return { name: params.name };
222
224
  }
223
225
  },
@@ -458,51 +460,67 @@ function matchRoute(routes, method, pathname) {
458
460
  */
459
461
  function hasToken(header, token) {
460
462
  const match = /^Bearer (.+)$/i.exec(header || '');
461
- if (!match) {
462
- return false;
463
- }
463
+ return !!match && safeEqual(match[1], token);
464
+ }
465
+ /**
466
+ * Compares two strings in constant time, also when their lengths differ
467
+ *
468
+ * @param {String} a First
469
+ * @param {String} b Second
470
+ * @return {Boolean} true if equal
471
+ */
472
+ function safeEqual(a, b) {
464
473
  const digest = (value) => node_crypto_1.default.createHash('sha256').update(value).digest();
465
- return node_crypto_1.default.timingSafeEqual(digest(match[1]), digest(token));
474
+ return node_crypto_1.default.timingSafeEqual(digest(a), digest(b));
466
475
  }
467
476
  /**
468
- * Reads a JSON request body
477
+ * Reads a request body
469
478
  *
470
479
  * @param {Object} req HTTP request
471
- * @return {Promise<Object>} the body, an empty object without one
480
+ * @param {Number} limit Largest body in octets
481
+ * @param {Function} tooBig Makes the error for a larger body
482
+ * @return {Promise<Buffer>} the body
472
483
  */
473
- function readBody(req) {
484
+ function readRaw(req, limit, tooBig) {
474
485
  return new Promise((resolve, reject) => {
475
486
  const chunks = [];
476
487
  let size = 0;
477
488
  req.on('data', (chunk) => {
478
489
  size += chunk.length;
479
- if (size > BODY_LIMIT) {
480
- reject((0, store_operations_js_1.storeError)('Request body is larger than 64 MiB', 'TOOBIG'));
490
+ if (size > limit) {
491
+ reject(tooBig());
481
492
  req.destroy();
482
493
  return;
483
494
  }
484
495
  chunks.push(chunk);
485
496
  });
486
497
  req.on('error', reject);
487
- req.on('end', () => {
488
- const text = Buffer.concat(chunks).toString('utf-8');
489
- if (!text) {
490
- return resolve({});
491
- }
492
- let body;
493
- try {
494
- body = JSON.parse(text);
495
- }
496
- catch {
497
- return reject((0, store_operations_js_1.storeError)('Request body is not valid JSON', 'INVALID'));
498
- }
499
- if (!body || typeof body !== 'object') {
500
- return reject((0, store_operations_js_1.storeError)('Request body must be a JSON object or array', 'INVALID'));
501
- }
502
- resolve(body);
503
- });
498
+ req.on('end', () => resolve(Buffer.concat(chunks)));
504
499
  });
505
500
  }
501
+ /**
502
+ * Reads a JSON request body
503
+ *
504
+ * @param {Object} req HTTP request
505
+ * @return {Promise<Object>} the body, an empty object without one
506
+ */
507
+ async function readBody(req) {
508
+ const text = (await readRaw(req, BODY_LIMIT, () => (0, store_operations_js_1.storeError)('Request body is larger than 64 MiB', 'TOOBIG'))).toString('utf-8');
509
+ if (!text) {
510
+ return {};
511
+ }
512
+ let body;
513
+ try {
514
+ body = JSON.parse(text);
515
+ }
516
+ catch {
517
+ throw (0, store_operations_js_1.storeError)('Request body is not valid JSON', 'INVALID');
518
+ }
519
+ if (!body || typeof body !== 'object') {
520
+ throw (0, store_operations_js_1.storeError)('Request body must be a JSON object or array', 'INVALID');
521
+ }
522
+ return body;
523
+ }
506
524
  /**
507
525
  * Creates the HTTP server of the REST API, it does not listen yet
508
526
  *
@@ -5,6 +5,9 @@ import type { CompilerOptions, ParserOptions } from 'imap-handler';
5
5
  import type { ResolvedCommandOptions } from './command-states.js';
6
6
  import { Control } from './control.js';
7
7
  import type { RestServer } from './rest.js';
8
+ import { OAuthProvider } from './oauth/provider.js';
9
+ import type { TokenCheck } from './oauth/provider.js';
10
+ import type http from 'node:http';
8
11
  import type { SessionInfo } from './control.js';
9
12
  import type { MemorySocket } from './memory-socket.js';
10
13
  import { ServerScript } from './script.js';
@@ -89,6 +92,10 @@ declare class IMAPServer extends Stream {
89
92
  smtpServer: SMTPListener | null;
90
93
  /** the HTTP server of the REST API that `start()` runs for the `rest` option */
91
94
  restServer: RestServer | null;
95
+ /** the OAuth 2.0 / OpenID Connect provider of the `oauth` option, see src/oauth/provider.ts */
96
+ oauth: OAuthProvider | null;
97
+ /** the HTTP server of the OAuth provider that `start()` runs */
98
+ oauthServer: http.Server | null;
92
99
  constructor(options?: IMAPServerOptions);
93
100
  /**
94
101
  * Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
@@ -123,6 +130,23 @@ declare class IMAPServer extends Stream {
123
130
  * @return {Promise<Number>} resolves with the HTTP port
124
131
  */
125
132
  startRest(): Promise<number>;
133
+ /**
134
+ * Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
135
+ * URL unless the `issuer` option sets another
136
+ *
137
+ * @return {Promise<Number>} resolves with the HTTP port
138
+ */
139
+ startOAuth(): Promise<number>;
140
+ /**
141
+ * Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
142
+ * `xoauth2.accessToken` of a user, which never expires
143
+ *
144
+ * @param {String} token Access token
145
+ * @param {String} [username] The user the client logs in as, the owner of the token if not set
146
+ * @param {String} [via] "imap" or "smtp", for the events of the provider
147
+ * @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
148
+ */
149
+ checkAccessToken(token: string, username?: string, via?: string): TokenCheck;
126
150
  /**
127
151
  * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
128
152
  *
@@ -158,7 +182,7 @@ declare class IMAPServer extends Stream {
158
182
  */
159
183
  closeImapListeners(callback?: (err?: Error) => void): void;
160
184
  /**
161
- * Stops the SMTP and REST listeners of `start()`
185
+ * Stops the SMTP, REST and OAuth listeners of `start()`
162
186
  *
163
187
  * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
164
188
  * is answered, like POST /v1/shutdown, finishes first)
@@ -58,6 +58,8 @@ const control_js_1 = require("./control.js");
58
58
  const quirks_js_1 = require("./quirks.js");
59
59
  const storage_schema_js_1 = require("./storage-schema.js");
60
60
  const rest_js_1 = require("./rest.js");
61
+ const provider_js_1 = require("./oauth/provider.js");
62
+ const http_js_1 = require("./oauth/http.js");
61
63
  const bundledCert = __importStar(require("./cert.js"));
62
64
  const memory_socket_js_1 = require("./memory-socket.js");
63
65
  const script_js_1 = require("./script.js");
@@ -234,6 +236,9 @@ class IMAPServer extends node_stream_1.Stream {
234
236
  this.loadUsers();
235
237
  // plugins register their control operations
236
238
  this.control = new control_js_1.Control(this);
239
+ // registers its control operations too, so it exists only with the option
240
+ this.oauth = this.options.oauth ? new provider_js_1.OAuthProvider(this, this.options.oauth) : null;
241
+ this.oauthServer = null;
237
242
  // a quirk preset can leave plugins out (no-move, no-uidplus)
238
243
  (0, load_plugins_js_1.default)(this, this.options.plugins, quirks.removePlugins);
239
244
  if (this.options.storage) {
@@ -263,6 +268,13 @@ class IMAPServer extends node_stream_1.Stream {
263
268
  }
264
269
  }
265
270
  });
271
+ // a typo in an OAuth consent policy fails here
272
+ for (const name of Object.keys(this.users)) {
273
+ const user = this.users[name];
274
+ if (user && user.oauth !== undefined) {
275
+ user.oauth = (0, control_js_1.checkUserOAuth)(user.oauth, 'users[' + JSON.stringify(name) + '].oauth');
276
+ }
277
+ }
266
278
  }
267
279
  /**
268
280
  * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
@@ -296,7 +308,8 @@ class IMAPServer extends node_stream_1.Stream {
296
308
  await Promise.all([
297
309
  this.options.tls ? this.startTlsListener(host) : null,
298
310
  this.options.smtp ? this.startSmtp() : null,
299
- this.options.rest ? this.startRest() : null
311
+ this.options.rest ? this.startRest() : null,
312
+ this.oauth ? this.startOAuth() : null
300
313
  ]);
301
314
  return imapPort;
302
315
  }
@@ -322,6 +335,41 @@ class IMAPServer extends node_stream_1.Stream {
322
335
  this.restServer = (0, rest_js_1.createRestServer)(this, { host, token });
323
336
  return listenOn(this.restServer, port, host || '127.0.0.1');
324
337
  }
338
+ /**
339
+ * Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
340
+ * URL unless the `issuer` option sets another
341
+ *
342
+ * @return {Promise<Number>} resolves with the HTTP port
343
+ */
344
+ async startOAuth() {
345
+ const oauth = this.oauth;
346
+ const { port, host = '127.0.0.1', secure } = oauth.options;
347
+ this.oauthServer = (0, http_js_1.createOAuthServer)(oauth, { secure, host, credentials: this.getCredentials() });
348
+ const listening = await listenOn(this.oauthServer, port, host);
349
+ oauth.listening(host, listening);
350
+ return listening;
351
+ }
352
+ /**
353
+ * Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
354
+ * `xoauth2.accessToken` of a user, which never expires
355
+ *
356
+ * @param {String} token Access token
357
+ * @param {String} [username] The user the client logs in as, the owner of the token if not set
358
+ * @param {String} [via] "imap" or "smtp", for the events of the provider
359
+ * @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
360
+ */
361
+ checkAccessToken(token, username, via = 'imap') {
362
+ const issued = this.oauth && this.oauth.verifyAccessToken(token, username, via);
363
+ if (issued) {
364
+ return issued;
365
+ }
366
+ const owns = (name) => {
367
+ const user = this.users[name];
368
+ return !!(user && user.xoauth2 && user.xoauth2.accessToken === token);
369
+ };
370
+ const user = username === undefined ? Object.keys(this.users).find(owns) : owns(username) ? username : undefined;
371
+ return user === undefined ? { error: 'invalid_token', reason: 'unknown access token' } : { user };
372
+ }
325
373
  /**
326
374
  * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
327
375
  *
@@ -388,7 +436,7 @@ class IMAPServer extends node_stream_1.Stream {
388
436
  }));
389
437
  }
390
438
  /**
391
- * Stops the SMTP and REST listeners of `start()`
439
+ * Stops the SMTP, REST and OAuth listeners of `start()`
392
440
  *
393
441
  * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
394
442
  * is answered, like POST /v1/shutdown, finishes first)
@@ -401,16 +449,13 @@ class IMAPServer extends node_stream_1.Stream {
401
449
  if (this.restServer) {
402
450
  // event streams never end on their own, a graceful shutdown would wait for them forever
403
451
  this.restServer.endStreams?.();
404
- this.restServer.close();
405
- // keep-alive connections would hold the server open
406
- if (force) {
407
- this.restServer.closeAllConnections?.();
408
- }
409
- else {
410
- this.restServer.closeIdleConnections?.();
411
- }
452
+ closeHttp(this.restServer, force);
412
453
  this.restServer = null;
413
454
  }
455
+ if (this.oauthServer) {
456
+ closeHttp(this.oauthServer, force);
457
+ this.oauthServer = null;
458
+ }
414
459
  }
415
460
  /**
416
461
  * Returns TLS key and certificate. Without credentials in the options the
@@ -505,7 +550,8 @@ class IMAPServer extends node_stream_1.Stream {
505
550
  * @return {Object|false} User data or false if there is no such user
506
551
  */
507
552
  getUser(username) {
508
- return (typeof username === 'string' && this.users[username]) || false;
553
+ // users is a null-prototype object, but a plain object from the control API or a test may still be assigned
554
+ return (typeof username === 'string' && Object.hasOwn(this.users, username) && this.users[username]) || false;
509
555
  }
510
556
  /**
511
557
  * Returns a mailbox object from folderCache
@@ -3264,6 +3310,22 @@ const IS_DENO = 'Deno' in globalThis;
3264
3310
  function nextTurn(fn) {
3265
3311
  return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3266
3312
  }
3313
+ /**
3314
+ * Stops an HTTP listener of `start()`
3315
+ *
3316
+ * @param {Object} server HTTP server
3317
+ * @param {Boolean} force true closes the open connections too, otherwise only the idle ones (keep-alive connections
3318
+ * would hold the server open)
3319
+ */
3320
+ function closeHttp(server, force) {
3321
+ server.close();
3322
+ if (force) {
3323
+ server.closeAllConnections?.();
3324
+ }
3325
+ else {
3326
+ server.closeIdleConnections?.();
3327
+ }
3328
+ }
3267
3329
  /**
3268
3330
  * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3269
3331
  *
@@ -2,8 +2,9 @@ import { SMTPServer } from 'smtp-server';
2
2
  import type { IMAPServer } from './types.js';
3
3
  /**
4
4
  * Starts an SMTP server that appends every received message to the INBOX
5
- * of the given ImapKit IMAP server. Any sender, recipient and credentials
6
- * are accepted.
5
+ * of the given ImapKit IMAP server. Any sender, recipient and password are
6
+ * accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
7
+ * the access token like the IMAP login does.
7
8
  *
8
9
  * @param {Number} smtpPort - port to listen on for SMTP commands
9
10
  * @param {Object} imapServer - the ImapKit IMAP server
@@ -4,8 +4,9 @@ exports.startSMTPServer = startSMTPServer;
4
4
  const smtp_server_1 = require("smtp-server");
5
5
  /**
6
6
  * Starts an SMTP server that appends every received message to the INBOX
7
- * of the given ImapKit IMAP server. Any sender, recipient and credentials
8
- * are accepted.
7
+ * of the given ImapKit IMAP server. Any sender, recipient and password are
8
+ * accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
9
+ * the access token like the IMAP login does.
9
10
  *
10
11
  * @param {Number} smtpPort - port to listen on for SMTP commands
11
12
  * @param {Object} imapServer - the ImapKit IMAP server
@@ -20,9 +21,20 @@ function startSMTPServer(smtpPort, imapServer, callback, host) {
20
21
  key: credentials.key,
21
22
  cert: credentials.cert,
22
23
  authOptional: true,
24
+ // with the OAuth provider, clients can also log in with its access tokens
25
+ authMethods: imapServer.oauth ? ['PLAIN', 'LOGIN', 'XOAUTH2'] : ['PLAIN', 'LOGIN'],
23
26
  logger: false,
24
27
  onAuth(auth, session, done) {
25
- done(null, { user: auth.username });
28
+ // XOAUTH2 is only offered with the OAuth provider
29
+ if (auth.method !== 'XOAUTH2') {
30
+ return done(null, { user: auth.username });
31
+ }
32
+ const result = imapServer.checkAccessToken(auth.accessToken || '', auth.username, 'smtp');
33
+ if ('error' in result) {
34
+ // the JSON error of Gmail's XOAUTH2, the client answers it and gets 535
35
+ return done(null, { data: { status: '401', schemes: 'bearer', scope: imapServer.oauth.mailScope } });
36
+ }
37
+ done(null, { user: result.user });
26
38
  },
27
39
  onData(stream, session, done) {
28
40
  const chunks = [];
@@ -1,5 +1,7 @@
1
1
  import type { IMAPServer, IMAPConnection } from './server.js';
2
2
  import type { ScriptRule } from './script.js';
3
+ import type { OAuthOptions } from './oauth/provider.js';
4
+ import type { ConsentPolicy } from './oauth/policy.js';
3
5
  export type { IMAPServer, IMAPConnection };
4
6
  /**
5
7
  * A value of the parsed command or of a response, as imap-handler parses and compiles it: an
@@ -210,6 +212,12 @@ export interface MailboxStatus {
210
212
  }
211
213
  export interface UserData {
212
214
  password?: string | undefined;
215
+ /** the display name, the `name` claim of the OAuth provider */
216
+ displayName?: string | undefined;
217
+ /** OAuth provider settings of the user: the consent policy of sign-ins that name the user in login_hint */
218
+ oauth?: {
219
+ consent?: ConsentPolicy | undefined;
220
+ } | undefined;
213
221
  xoauth2?: {
214
222
  accessToken?: string | undefined;
215
223
  /**
@@ -246,6 +254,8 @@ export interface IMAPServerOptions {
246
254
  host?: string | undefined;
247
255
  token?: string | undefined;
248
256
  } | undefined;
257
+ /** starts the OAuth 2.0 / OpenID Connect provider with `server.start()`, see src/oauth/provider.ts */
258
+ oauth?: OAuthOptions | undefined;
249
259
  credentials?: {
250
260
  key: string | Buffer;
251
261
  cert: string | Buffer;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { ImapKitError } from './store-operations.js';
10
10
  import type { FlagMode, UidValidityOptions } from './store-operations.js';
11
+ import type { ConsentPolicy } from './oauth/policy.js';
11
12
  import type { IMAPConnection, IMAPServer, Mailbox, Message, StorageMailbox, StorageNamespace, UserData } from './types.js';
12
13
  /** A mailbox as `getMailbox()` and `listMailboxes()` describe it */
13
14
  interface MailboxInfo {
@@ -68,7 +69,7 @@ interface NewMessage {
68
69
  internaldate?: Date | string | undefined;
69
70
  }
70
71
  /** The credentials of a user, see UserData */
71
- type UserOptions = Pick<UserData, 'password' | 'xoauth2'>;
72
+ type UserOptions = Pick<UserData, 'password' | 'xoauth2' | 'displayName' | 'oauth'>;
72
73
  /** What a REST route handler gets, see src/rest.ts */
73
74
  interface RouteRequest {
74
75
  params: Record<string, string>;
@@ -89,6 +90,16 @@ type SessionFilter = number | {
89
90
  session?: number | undefined;
90
91
  user?: string | undefined;
91
92
  };
93
+ /**
94
+ * Checks the `oauth` settings of a user
95
+ *
96
+ * @param {*} value `{ consent }`
97
+ * @param {String} what Setting name for the error message
98
+ * @return {Object} a copy
99
+ */
100
+ declare function checkUserOAuth(value: unknown, what: string): {
101
+ consent?: ConsentPolicy;
102
+ };
92
103
  declare class Control {
93
104
  [key: string]: any;
94
105
  server: IMAPServer;
@@ -364,14 +375,14 @@ declare class Control {
364
375
  * Adds a user
365
376
  *
366
377
  * @param {String} name User name
367
- * @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout } }`
378
+ * @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout }, displayName, oauth: { consent } }`
368
379
  */
369
380
  addUser(name: string, options: UserOptions): void;
370
381
  /**
371
- * Changes the password or the XOAUTH2 token of a user
382
+ * Changes the password, the XOAUTH2 token, the display name or the OAuth consent policy of a user
372
383
  *
373
384
  * @param {String} name User name
374
- * @param {Object} options `{ password, xoauth2 }`, null xoauth2 removes the token
385
+ * @param {Object} options `{ password, xoauth2, displayName, oauth }`, null xoauth2, displayName or oauth removes it
375
386
  */
376
387
  updateUser(name: string, options: UserOptions): void;
377
388
  /**
@@ -421,5 +432,5 @@ declare class Control {
421
432
  graceful?: boolean | undefined;
422
433
  }): Promise<void>;
423
434
  }
424
- export { Control, ImapKitError };
435
+ export { Control, ImapKitError, checkUserOAuth };
425
436
  export type { MailboxInfo, MessageInfo, SessionInfo, NewMessage, UserOptions, SessionFilter, ControlRoute, RouteRequest };
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import validateMailboxName from './mailbox-name.js';
10
10
  import { DEFAULT_SESSION_TIMEOUT, ImapKitError, expungeMessages, checkFlags, changeFlags, createMailbox, deleteMailbox, renameMailbox, resetUidValidity, subscribeMailbox, unsubscribeMailbox } from './store-operations.js';
11
+ import { checkOutcome } from './oauth/policy.js';
11
12
  /**
12
13
  * Copies a value of a plugin into the snapshot as JSON data, values that JSON can not hold are left out
13
14
  *
@@ -43,6 +44,20 @@ const NAMESPACE_SKIP = new Set(['folders']);
43
44
  // properties the server derives when it loads a mailbox, a snapshot leaves them out
44
45
  const DERIVED_MAILBOX_KEYS = new Set(['path', 'namespace', 'messages', 'folders', 'subscribed', 'flags']);
45
46
  const DERIVED_FLAGS = new Set(['\\HasChildren', '\\HasNoChildren']);
47
+ /**
48
+ * Checks the `oauth` settings of a user
49
+ *
50
+ * @param {*} value `{ consent }`
51
+ * @param {String} what Setting name for the error message
52
+ * @return {Object} a copy
53
+ */
54
+ function checkUserOAuth(value, what) {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
56
+ throw new ImapKitError(what + ' must be { consent }', 'INVALID');
57
+ }
58
+ const { consent } = value;
59
+ return consent === undefined ? {} : { consent: checkOutcome(consent, what + '.consent') };
60
+ }
46
61
  class Control {
47
62
  constructor(server) {
48
63
  this.server = server;
@@ -494,7 +509,7 @@ class Control {
494
509
  * Adds a user
495
510
  *
496
511
  * @param {String} name User name
497
- * @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout } }`
512
+ * @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout }, displayName, oauth: { consent } }`
498
513
  */
499
514
  addUser(name, options) {
500
515
  if (typeof name !== 'string' || !name) {
@@ -506,10 +521,10 @@ class Control {
506
521
  this.server.users[name] = this.userData({}, options);
507
522
  }
508
523
  /**
509
- * Changes the password or the XOAUTH2 token of a user
524
+ * Changes the password, the XOAUTH2 token, the display name or the OAuth consent policy of a user
510
525
  *
511
526
  * @param {String} name User name
512
- * @param {Object} options `{ password, xoauth2 }`, null xoauth2 removes the token
527
+ * @param {Object} options `{ password, xoauth2, displayName, oauth }`, null xoauth2, displayName or oauth removes it
513
528
  */
514
529
  updateUser(name, options) {
515
530
  this.server.users[name] = this.userData(this.requireUser(name), options);
@@ -534,8 +549,23 @@ class Control {
534
549
  return this.server.users[name];
535
550
  }
536
551
  userData(user, options) {
537
- const { password, xoauth2 } = options || {};
552
+ const { password, xoauth2, displayName, oauth } = options || {};
538
553
  const result = Object.assign({}, user);
554
+ if (displayName === null) {
555
+ delete result.displayName;
556
+ }
557
+ else if (displayName !== undefined) {
558
+ if (typeof displayName !== 'string') {
559
+ throw new ImapKitError('displayName must be a string', 'INVALID');
560
+ }
561
+ result.displayName = displayName;
562
+ }
563
+ if (oauth === null) {
564
+ delete result.oauth;
565
+ }
566
+ else if (oauth !== undefined) {
567
+ result.oauth = checkUserOAuth(oauth, 'oauth');
568
+ }
539
569
  if (password !== undefined) {
540
570
  if (typeof password !== 'string') {
541
571
  throw new ImapKitError('Password must be a string', 'INVALID');
@@ -627,4 +657,4 @@ class Control {
627
657
  });
628
658
  }
629
659
  }
630
- export { Control, ImapKitError };
660
+ export { Control, ImapKitError, checkUserOAuth };