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
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
]
|
package/dist/cjs/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/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
|
|
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
|
-
|
|
462
|
-
|
|
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(
|
|
474
|
+
return node_crypto_1.default.timingSafeEqual(digest(a), digest(b));
|
|
466
475
|
}
|
|
467
476
|
/**
|
|
468
|
-
* Reads a
|
|
477
|
+
* Reads a request body
|
|
469
478
|
*
|
|
470
479
|
* @param {Object} req HTTP request
|
|
471
|
-
* @
|
|
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
|
|
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 >
|
|
480
|
-
reject((
|
|
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
|
*
|
package/dist/cjs/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/cjs/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
|
|
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 = [];
|
package/dist/cjs/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/esm/control.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
package/dist/esm/control.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|