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
@@ -3,7 +3,7 @@
3
3
  * @help Works with and without SASL-IR [RFC4959]
4
4
  * @help Valid login info, same as for XOAUTH2:
5
5
  * @help Username (authzid, optional): testuser
6
- * @help Access Token: testtoken
6
+ * @help Access Token: testtoken, or a token of the OAuth provider
7
7
  */
8
8
  const KVSEP = '\x01';
9
9
  const utf8 = new TextDecoder('utf-8', { fatal: true });
@@ -89,21 +89,16 @@ export default function oauthbearerPlugin(server) {
89
89
  return auth === '' ? 'invalid_token' : 'invalid_request';
90
90
  }
91
91
  const token = bearer[1];
92
- let username = request.authzid;
93
- if (username === false) {
94
- // without an authzid the token tells who the user is
95
- username = Object.keys(server.users).find(name => {
96
- const user = server.users[name];
97
- return user && user.xoauth2 && user.xoauth2.accessToken === token;
98
- });
99
- }
100
- const user = server.getUser(username);
101
- if (!user || !user.xoauth2 || user.xoauth2.accessToken !== token) {
102
- return 'invalid_token';
103
- }
104
- request.username = username;
92
+ // without an authzid the token tells who the user is
93
+ const result = server.checkAccessToken(token, request.authzid === false ? undefined : request.authzid);
94
+ if ('error' in result) {
95
+ return result.error;
96
+ }
97
+ request.username = result.user;
105
98
  return false;
106
99
  };
100
+ // RFC 7628 section 3.2.2: with the OAuth provider the error result also says where to get a token and for which scope
101
+ const errorResult = (status) => server.oauth && server.oauth.issuer ? { status, scope: server.oauth.mailScope, 'openid-configuration': server.oauth.discoveryUrl() } : { status };
107
102
  // Decodes a base64 client response, sends BAD and returns false if it is not valid base64
108
103
  const decode = (connection, parsed, data, input) => {
109
104
  const decoded = connection.decodeSaslResponse(input);
@@ -155,7 +150,7 @@ export default function oauthbearerPlugin(server) {
155
150
  attributes: [
156
151
  {
157
152
  type: 'ATOM',
158
- value: Buffer.from(JSON.stringify({ status })).toString('base64')
153
+ value: Buffer.from(JSON.stringify(errorResult(status))).toString('base64')
159
154
  }
160
155
  ]
161
156
  }, 'AUTHENTICATE OAUTHBEARER CHALLENGE', parsed, data);
@@ -5,7 +5,7 @@
5
5
  * @help SASL-IR enabled and used while Gmail does not
6
6
  * @help Valid login info:
7
7
  * @help Username: testuser
8
- * @help Access Token: testtoken
8
+ * @help Access Token: testtoken, or a token of the OAuth provider
9
9
  */
10
10
  const utf8 = new TextDecoder('utf-8', { fatal: true });
11
11
  export default function xoauth2Plugin(server) {
@@ -91,7 +91,7 @@ export default function xoauth2Plugin(server) {
91
91
  }, 'AUTHENTICATE XOAUTH2 FAILED', parsed, data);
92
92
  return callback();
93
93
  }
94
- if (!userData.xoauth2 || userData.xoauth2.accessToken !== accessToken) {
94
+ if ('error' in server.checkAccessToken(accessToken, user)) {
95
95
  connection.send({
96
96
  tag: '+',
97
97
  attributes: [
@@ -100,7 +100,8 @@ export default function xoauth2Plugin(server) {
100
100
  value: Buffer.from(JSON.stringify({
101
101
  status: '400',
102
102
  schemes: 'Bearer',
103
- scope: 'https://mail.google.com/'
103
+ // the scope the OAuth provider of this server asks for, Gmail's without one
104
+ scope: server.oauth ? server.oauth.mailScope : 'https://mail.google.com/'
104
105
  })).toString('base64')
105
106
  }
106
107
  ]
@@ -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/esm/rest.js CHANGED
@@ -61,7 +61,7 @@ function decodeBody(value, encoding) {
61
61
  return encoding === 'base64' && typeof value === 'string' ? Buffer.from(value, 'base64') : value;
62
62
  }
63
63
  // the server events that GET /v1/events streams by default
64
- const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset'];
64
+ const EVENT_TYPES = ['session', 'command', 'mailbox', 'append', 'expunge', 'flags', 'acl', 'script', 'reset', 'oauth'];
65
65
  // event types that are streamed only when ?types= asks for them, the protocol trace of every session is a lot of data
66
66
  const OPT_IN_EVENT_TYPES = ['trace'];
67
67
  const sessionNumber = (origin) => (origin ? origin.sessionNumber : null);
@@ -197,18 +197,18 @@ function getRoutes(server, streams = new Set()) {
197
197
  {
198
198
  method: 'POST',
199
199
  path: '/v1/users',
200
- summary: 'Adds a user ({ name, password, xoauth2 })',
200
+ summary: 'Adds a user ({ name, password, xoauth2, displayName, oauth })',
201
201
  handler: ({ body }) => {
202
- control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2 });
202
+ control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
203
203
  return { status: 201, body: { name: body.name } };
204
204
  }
205
205
  },
206
206
  {
207
207
  method: 'PUT',
208
208
  path: '/v1/users/{name}',
209
- summary: 'Changes the password or the XOAUTH2 token of a user',
209
+ summary: 'Changes the password, the XOAUTH2 token, the display name (displayName) or the OAuth settings ({ oauth: { consent } }) of a user',
210
210
  handler: ({ params, body }) => {
211
- control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2 });
211
+ control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2, displayName: body.displayName, oauth: body.oauth });
212
212
  return { name: params.name };
213
213
  }
214
214
  },
@@ -449,51 +449,67 @@ function matchRoute(routes, method, pathname) {
449
449
  */
450
450
  function hasToken(header, token) {
451
451
  const match = /^Bearer (.+)$/i.exec(header || '');
452
- if (!match) {
453
- return false;
454
- }
452
+ return !!match && safeEqual(match[1], token);
453
+ }
454
+ /**
455
+ * Compares two strings in constant time, also when their lengths differ
456
+ *
457
+ * @param {String} a First
458
+ * @param {String} b Second
459
+ * @return {Boolean} true if equal
460
+ */
461
+ function safeEqual(a, b) {
455
462
  const digest = (value) => crypto.createHash('sha256').update(value).digest();
456
- return crypto.timingSafeEqual(digest(match[1]), digest(token));
463
+ return crypto.timingSafeEqual(digest(a), digest(b));
457
464
  }
458
465
  /**
459
- * Reads a JSON request body
466
+ * Reads a request body
460
467
  *
461
468
  * @param {Object} req HTTP request
462
- * @return {Promise<Object>} the body, an empty object without one
469
+ * @param {Number} limit Largest body in octets
470
+ * @param {Function} tooBig Makes the error for a larger body
471
+ * @return {Promise<Buffer>} the body
463
472
  */
464
- function readBody(req) {
473
+ function readRaw(req, limit, tooBig) {
465
474
  return new Promise((resolve, reject) => {
466
475
  const chunks = [];
467
476
  let size = 0;
468
477
  req.on('data', (chunk) => {
469
478
  size += chunk.length;
470
- if (size > BODY_LIMIT) {
471
- reject(storeError('Request body is larger than 64 MiB', 'TOOBIG'));
479
+ if (size > limit) {
480
+ reject(tooBig());
472
481
  req.destroy();
473
482
  return;
474
483
  }
475
484
  chunks.push(chunk);
476
485
  });
477
486
  req.on('error', reject);
478
- req.on('end', () => {
479
- const text = Buffer.concat(chunks).toString('utf-8');
480
- if (!text) {
481
- return resolve({});
482
- }
483
- let body;
484
- try {
485
- body = JSON.parse(text);
486
- }
487
- catch {
488
- return reject(storeError('Request body is not valid JSON', 'INVALID'));
489
- }
490
- if (!body || typeof body !== 'object') {
491
- return reject(storeError('Request body must be a JSON object or array', 'INVALID'));
492
- }
493
- resolve(body);
494
- });
487
+ req.on('end', () => resolve(Buffer.concat(chunks)));
495
488
  });
496
489
  }
490
+ /**
491
+ * Reads a JSON request body
492
+ *
493
+ * @param {Object} req HTTP request
494
+ * @return {Promise<Object>} the body, an empty object without one
495
+ */
496
+ async function readBody(req) {
497
+ const text = (await readRaw(req, BODY_LIMIT, () => storeError('Request body is larger than 64 MiB', 'TOOBIG'))).toString('utf-8');
498
+ if (!text) {
499
+ return {};
500
+ }
501
+ let body;
502
+ try {
503
+ body = JSON.parse(text);
504
+ }
505
+ catch {
506
+ throw storeError('Request body is not valid JSON', 'INVALID');
507
+ }
508
+ if (!body || typeof body !== 'object') {
509
+ throw storeError('Request body must be a JSON object or array', 'INVALID');
510
+ }
511
+ return body;
512
+ }
497
513
  /**
498
514
  * Creates the HTTP server of the REST API, it does not listen yet
499
515
  *
@@ -566,4 +582,4 @@ function createRestServer(server, options) {
566
582
  rest.endStreams = () => streams.forEach(res => res.end());
567
583
  return rest;
568
584
  }
569
- export { createRestServer, isLoopback, getRoutes, openApi };
585
+ export { createRestServer, isLoopback, safeEqual, readRaw, getRoutes, openApi };
@@ -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)
@@ -14,10 +14,12 @@ import { isSequenceSet } from './numbers.js';
14
14
  import { restoreNilAtoms, isAtom } from './arguments.js';
15
15
  import { refuseMissingTarget } from './commands/append.js';
16
16
  import { DEFAULT_SESSION_TIMEOUT, storeError, expungeMessages, notifyFlagChanges } from './store-operations.js';
17
- import { Control } from './control.js';
17
+ import { Control, checkUserOAuth } from './control.js';
18
18
  import { resolveQuirks } from './quirks.js';
19
19
  import { validateStorage } from './storage-schema.js';
20
20
  import { createRestServer } from './rest.js';
21
+ import { OAuthProvider } from './oauth/provider.js';
22
+ import { createOAuthServer } from './oauth/http.js';
21
23
  import * as bundledCert from './cert.js';
22
24
  import { createSocketPair } from './memory-socket.js';
23
25
  import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js';
@@ -193,6 +195,9 @@ class IMAPServer extends Stream {
193
195
  this.loadUsers();
194
196
  // plugins register their control operations
195
197
  this.control = new Control(this);
198
+ // registers its control operations too, so it exists only with the option
199
+ this.oauth = this.options.oauth ? new OAuthProvider(this, this.options.oauth) : null;
200
+ this.oauthServer = null;
196
201
  // a quirk preset can leave plugins out (no-move, no-uidplus)
197
202
  loadPlugins(this, this.options.plugins, quirks.removePlugins);
198
203
  if (this.options.storage) {
@@ -222,6 +227,13 @@ class IMAPServer extends Stream {
222
227
  }
223
228
  }
224
229
  });
230
+ // a typo in an OAuth consent policy fails here
231
+ for (const name of Object.keys(this.users)) {
232
+ const user = this.users[name];
233
+ if (user && user.oauth !== undefined) {
234
+ user.oauth = checkUserOAuth(user.oauth, 'users[' + JSON.stringify(name) + '].oauth');
235
+ }
236
+ }
225
237
  }
226
238
  /**
227
239
  * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
@@ -255,7 +267,8 @@ class IMAPServer extends Stream {
255
267
  await Promise.all([
256
268
  this.options.tls ? this.startTlsListener(host) : null,
257
269
  this.options.smtp ? this.startSmtp() : null,
258
- this.options.rest ? this.startRest() : null
270
+ this.options.rest ? this.startRest() : null,
271
+ this.oauth ? this.startOAuth() : null
259
272
  ]);
260
273
  return imapPort;
261
274
  }
@@ -281,6 +294,41 @@ class IMAPServer extends Stream {
281
294
  this.restServer = createRestServer(this, { host, token });
282
295
  return listenOn(this.restServer, port, host || '127.0.0.1');
283
296
  }
297
+ /**
298
+ * Starts the HTTP server of the OAuth provider (the `oauth` option, see src/oauth/http.ts). The issuer is its
299
+ * URL unless the `issuer` option sets another
300
+ *
301
+ * @return {Promise<Number>} resolves with the HTTP port
302
+ */
303
+ async startOAuth() {
304
+ const oauth = this.oauth;
305
+ const { port, host = '127.0.0.1', secure } = oauth.options;
306
+ this.oauthServer = createOAuthServer(oauth, { secure, host, credentials: this.getCredentials() });
307
+ const listening = await listenOn(this.oauthServer, port, host);
308
+ oauth.listening(host, listening);
309
+ return listening;
310
+ }
311
+ /**
312
+ * Checks a bearer token of an IMAP or SMTP login: a token of the OAuth provider, or the static
313
+ * `xoauth2.accessToken` of a user, which never expires
314
+ *
315
+ * @param {String} token Access token
316
+ * @param {String} [username] The user the client logs in as, the owner of the token if not set
317
+ * @param {String} [via] "imap" or "smtp", for the events of the provider
318
+ * @return {Object} `{ user }`, or `{ error, reason }` with the OAuth error code (invalid_token, insufficient_scope)
319
+ */
320
+ checkAccessToken(token, username, via = 'imap') {
321
+ const issued = this.oauth && this.oauth.verifyAccessToken(token, username, via);
322
+ if (issued) {
323
+ return issued;
324
+ }
325
+ const owns = (name) => {
326
+ const user = this.users[name];
327
+ return !!(user && user.xoauth2 && user.xoauth2.accessToken === token);
328
+ };
329
+ const user = username === undefined ? Object.keys(this.users).find(owns) : owns(username) ? username : undefined;
330
+ return user === undefined ? { error: 'invalid_token', reason: 'unknown access token' } : { user };
331
+ }
284
332
  /**
285
333
  * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
286
334
  *
@@ -347,7 +395,7 @@ class IMAPServer extends Stream {
347
395
  }));
348
396
  }
349
397
  /**
350
- * Stops the SMTP and REST listeners of `start()`
398
+ * Stops the SMTP, REST and OAuth listeners of `start()`
351
399
  *
352
400
  * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
353
401
  * is answered, like POST /v1/shutdown, finishes first)
@@ -360,16 +408,13 @@ class IMAPServer extends Stream {
360
408
  if (this.restServer) {
361
409
  // event streams never end on their own, a graceful shutdown would wait for them forever
362
410
  this.restServer.endStreams?.();
363
- this.restServer.close();
364
- // keep-alive connections would hold the server open
365
- if (force) {
366
- this.restServer.closeAllConnections?.();
367
- }
368
- else {
369
- this.restServer.closeIdleConnections?.();
370
- }
411
+ closeHttp(this.restServer, force);
371
412
  this.restServer = null;
372
413
  }
414
+ if (this.oauthServer) {
415
+ closeHttp(this.oauthServer, force);
416
+ this.oauthServer = null;
417
+ }
373
418
  }
374
419
  /**
375
420
  * Returns TLS key and certificate. Without credentials in the options the
@@ -464,7 +509,8 @@ class IMAPServer extends Stream {
464
509
  * @return {Object|false} User data or false if there is no such user
465
510
  */
466
511
  getUser(username) {
467
- return (typeof username === 'string' && this.users[username]) || false;
512
+ // users is a null-prototype object, but a plain object from the control API or a test may still be assigned
513
+ return (typeof username === 'string' && Object.hasOwn(this.users, username) && this.users[username]) || false;
468
514
  }
469
515
  /**
470
516
  * Returns a mailbox object from folderCache
@@ -3221,6 +3267,22 @@ const IS_DENO = 'Deno' in globalThis;
3221
3267
  function nextTurn(fn) {
3222
3268
  return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3223
3269
  }
3270
+ /**
3271
+ * Stops an HTTP listener of `start()`
3272
+ *
3273
+ * @param {Object} server HTTP server
3274
+ * @param {Boolean} force true closes the open connections too, otherwise only the idle ones (keep-alive connections
3275
+ * would hold the server open)
3276
+ */
3277
+ function closeHttp(server, force) {
3278
+ server.close();
3279
+ if (force) {
3280
+ server.closeAllConnections?.();
3281
+ }
3282
+ else {
3283
+ server.closeIdleConnections?.();
3284
+ }
3285
+ }
3224
3286
  /**
3225
3287
  * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3226
3288
  *
@@ -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
@@ -1,8 +1,9 @@
1
1
  import { SMTPServer } from 'smtp-server';
2
2
  /**
3
3
  * Starts an SMTP server that appends every received message to the INBOX
4
- * of the given ImapKit IMAP server. Any sender, recipient and credentials
5
- * are accepted.
4
+ * of the given ImapKit IMAP server. Any sender, recipient and password are
5
+ * accepted. With the OAuth provider, AUTH XOAUTH2 is offered too and checks
6
+ * the access token like the IMAP login does.
6
7
  *
7
8
  * @param {Number} smtpPort - port to listen on for SMTP commands
8
9
  * @param {Object} imapServer - the ImapKit IMAP server
@@ -17,9 +18,20 @@ export function startSMTPServer(smtpPort, imapServer, callback, host) {
17
18
  key: credentials.key,
18
19
  cert: credentials.cert,
19
20
  authOptional: true,
21
+ // with the OAuth provider, clients can also log in with its access tokens
22
+ authMethods: imapServer.oauth ? ['PLAIN', 'LOGIN', 'XOAUTH2'] : ['PLAIN', 'LOGIN'],
20
23
  logger: false,
21
24
  onAuth(auth, session, done) {
22
- done(null, { user: auth.username });
25
+ // XOAUTH2 is only offered with the OAuth provider
26
+ if (auth.method !== 'XOAUTH2') {
27
+ return done(null, { user: auth.username });
28
+ }
29
+ const result = imapServer.checkAccessToken(auth.accessToken || '', auth.username, 'smtp');
30
+ if ('error' in result) {
31
+ // the JSON error of Gmail's XOAUTH2, the client answers it and gets 535
32
+ return done(null, { data: { status: '401', schemes: 'bearer', scope: imapServer.oauth.mailScope } });
33
+ }
34
+ done(null, { user: result.user });
23
35
  },
24
36
  onData(stream, session, done) {
25
37
  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;
@@ -144,7 +144,7 @@
144
144
  "Works with and without SASL-IR [RFC4959]",
145
145
  "Valid login info, same as for XOAUTH2:",
146
146
  " Username (authzid, optional): testuser",
147
- " Access Token: testtoken"
147
+ " Access Token: testtoken, or a token of the OAuth provider"
148
148
  ],
149
149
  "OBJECTID": [
150
150
  "Adds OBJECTID [RFC8474] capability",
@@ -280,6 +280,6 @@
280
280
  "SASL-IR enabled and used while Gmail does not",
281
281
  "Valid login info:",
282
282
  " Username: testuser",
283
- " Access Token: testtoken"
283
+ " Access Token: testtoken, or a token of the OAuth provider"
284
284
  ]
285
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapkit",
3
- "version": "5.2.0",
3
+ "version": "5.3.1",
4
4
  "description": "Scriptable, strictly RFC compliant in-memory IMAP server for testing IMAP clients",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -95,16 +95,16 @@
95
95
  "@types/smtp-server": "3.5.13",
96
96
  "buffer": "6.0.3",
97
97
  "esbuild": "0.28.2",
98
- "eslint": "10.13.0",
98
+ "eslint": "10.14.0",
99
99
  "eslint-config-prettier": "10.1.8",
100
100
  "events": "3.3.0",
101
- "globals": "17.13.0",
102
- "imapflow": "2.5.6",
101
+ "globals": "17.14.0",
102
+ "imapflow": "2.10.0",
103
103
  "prettier": "3.9.10",
104
- "smtp-server": "3.20.2",
104
+ "smtp-server": "3.21.3",
105
105
  "tsx": "4.23.15",
106
106
  "typescript": "6.0.3",
107
- "typescript-eslint": "8.71.2"
107
+ "typescript-eslint": "8.72.1"
108
108
  },
109
109
  "engines": {
110
110
  "node": ">=20.0.0"