imapkit 5.2.0 → 5.3.0
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.
- package/README.md +37 -21
- package/bin/help.txt +25 -1
- package/bin/imapkit.js +35 -0
- package/dist/browser/imapkit.js +99 -35
- package/dist/cjs/control.d.ts +16 -5
- package/dist/cjs/control.js +35 -4
- package/dist/cjs/oauth/http.d.ts +28 -0
- package/dist/cjs/oauth/http.js +216 -0
- package/dist/cjs/oauth/jwt.d.ts +33 -0
- package/dist/cjs/oauth/jwt.js +58 -0
- package/dist/cjs/oauth/pages.d.ts +47 -0
- package/dist/cjs/oauth/pages.js +310 -0
- package/dist/cjs/oauth/policy.d.ts +48 -0
- package/dist/cjs/oauth/policy.js +66 -0
- package/dist/cjs/oauth/provider.d.ts +455 -0
- package/dist/cjs/oauth/provider.js +1422 -0
- package/dist/cjs/plugins/oauthbearer.js +10 -15
- package/dist/cjs/plugins/xoauth2.js +4 -3
- package/dist/cjs/rest.d.ts +18 -1
- package/dist/cjs/rest.js +49 -31
- package/dist/cjs/server.d.ts +25 -1
- package/dist/cjs/server.js +73 -11
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +15 -3
- package/dist/cjs/types.d.ts +10 -0
- package/dist/esm/control.d.ts +16 -5
- package/dist/esm/control.js +35 -5
- package/dist/esm/oauth/http.d.ts +28 -0
- package/dist/esm/oauth/http.js +211 -0
- package/dist/esm/oauth/jwt.d.ts +33 -0
- package/dist/esm/oauth/jwt.js +50 -0
- package/dist/esm/oauth/pages.d.ts +47 -0
- package/dist/esm/oauth/pages.js +305 -0
- package/dist/esm/oauth/policy.d.ts +48 -0
- package/dist/esm/oauth/policy.js +59 -0
- package/dist/esm/oauth/provider.d.ts +455 -0
- package/dist/esm/oauth/provider.js +1415 -0
- package/dist/esm/plugins/oauthbearer.js +10 -15
- package/dist/esm/plugins/xoauth2.js +4 -3
- package/dist/esm/rest.d.ts +18 -1
- package/dist/esm/rest.js +48 -32
- package/dist/esm/server.d.ts +25 -1
- package/dist/esm/server.js +74 -12
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +15 -3
- package/dist/esm/types.d.ts +10 -0
- package/dist/plugin-help.json +2 -2
- 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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
]
|
package/dist/esm/rest.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
453
|
-
|
|
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(
|
|
463
|
+
return crypto.timingSafeEqual(digest(a), digest(b));
|
|
457
464
|
}
|
|
458
465
|
/**
|
|
459
|
-
* Reads a
|
|
466
|
+
* Reads a request body
|
|
460
467
|
*
|
|
461
468
|
* @param {Object} req HTTP request
|
|
462
|
-
* @
|
|
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
|
|
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 >
|
|
471
|
-
reject(
|
|
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 };
|
package/dist/esm/server.d.ts
CHANGED
|
@@ -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
|
|
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)
|
package/dist/esm/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
|
5
|
-
*
|
|
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
|
-
|
|
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 = [];
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin-help.json
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "5.3.0",
|
|
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.
|
|
98
|
+
"eslint": "10.14.0",
|
|
99
99
|
"eslint-config-prettier": "10.1.8",
|
|
100
100
|
"events": "3.3.0",
|
|
101
|
-
"globals": "17.
|
|
102
|
-
"imapflow": "2.
|
|
101
|
+
"globals": "17.14.0",
|
|
102
|
+
"imapflow": "2.10.0",
|
|
103
103
|
"prettier": "3.9.10",
|
|
104
|
-
"smtp-server": "3.
|
|
104
|
+
"smtp-server": "3.21.3",
|
|
105
105
|
"tsx": "4.23.15",
|
|
106
106
|
"typescript": "6.0.3",
|
|
107
|
-
"typescript-eslint": "8.
|
|
107
|
+
"typescript-eslint": "8.72.1"
|
|
108
108
|
},
|
|
109
109
|
"engines": {
|
|
110
110
|
"node": ">=20.0.0"
|