imapkit 5.1.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.
Files changed (48) hide show
  1. package/README.md +41 -23
  2. package/bin/help.txt +29 -3
  3. package/bin/imapkit.js +43 -0
  4. package/dist/browser/imapkit.js +99 -35
  5. package/dist/cjs/control.d.ts +16 -5
  6. package/dist/cjs/control.js +36 -5
  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 +310 -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 +63 -35
  21. package/dist/cjs/server.d.ts +49 -1
  22. package/dist/cjs/server.js +126 -12
  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 +16 -0
  26. package/dist/esm/control.d.ts +16 -5
  27. package/dist/esm/control.js +36 -6
  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 +305 -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 +62 -36
  42. package/dist/esm/server.d.ts +49 -1
  43. package/dist/esm/server.js +127 -13
  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 +16 -0
  47. package/dist/plugin-help.json +2 -2
  48. package/package.json +7 -7
@@ -0,0 +1,1415 @@
1
+ /**
2
+ * The OAuth 2.0 / OpenID Connect provider of the `oauth` option (README "OAuth 2.0 and OpenID Connect"): the
3
+ * authorization code flow with PKCE for mail clients, with tokens that IMAP (XOAUTH2, OAUTHBEARER) and SMTP (AUTH
4
+ * XOAUTH2) accept. No passwords: a consent policy decides every sign-in, from the first rung that has one: a queued
5
+ * decision, the user (picked by `login_hint`), the client, the server default. `prompt` shows the consent page,
6
+ * where the tester picks an account and clicks Allow or Deny. Strict like the IMAP side: a request that breaks a
7
+ * MUST of the RFCs is refused and the error description names the rule.
8
+ *
9
+ * This file holds the protocol logic and the state, src/oauth/http.ts the HTTP server and src/oauth/pages.ts the
10
+ * pages. Times come from `server.now()`, so tests with a fixed clock can let tokens expire.
11
+ */
12
+ import crypto from 'node:crypto';
13
+ import { ImapKitError } from '../store-operations.js';
14
+ import { isLoopback, safeEqual } from '../rest.js';
15
+ import { checkOutcome, describeOutcome, errorText, isErrorCode } from './policy.js';
16
+ import { defaultSigningKey, loadSigningKey, signJwt } from './jwt.js';
17
+ const LOOPBACK_REDIRECTS = ['http://127.0.0.1/*', 'http://localhost/*', 'http://[::1]/*'];
18
+ const DEFAULT_CLIENTS = {
19
+ imapkit: { name: 'ImapKit test client', redirectUris: LOOPBACK_REDIRECTS },
20
+ 'imapkit-approve': { name: 'ImapKit test client (always allows)', redirectUris: LOOPBACK_REDIRECTS, consent: 'approve' },
21
+ 'imapkit-deny': { name: 'ImapKit test client (always denies)', redirectUris: LOOPBACK_REDIRECTS, consent: 'deny' }
22
+ };
23
+ // OpenID Connect Core 1.0 section 5.4 and 11, the consent page shows these descriptions
24
+ const STANDARD_SCOPES = {
25
+ openid: 'Confirm who you are (OpenID Connect ID token)',
26
+ email: 'See your email address',
27
+ profile: 'See your name',
28
+ offline_access: 'Stay signed in (a refresh token)'
29
+ };
30
+ const PROMPT_VALUES = ['none', 'login', 'consent', 'select_account'];
31
+ // RFC 6749 section 3.3: scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
32
+ const SCOPE_TOKEN = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
33
+ // RFC 7636 section 4.1: code-verifier = 43*128unreserved, the S256 challenge is 43 base64url characters
34
+ const PKCE_VALUE = /^[A-Za-z0-9\-._~]{43,128}$/;
35
+ // parameters of an authorization request that the provider reads, the rest are listed as ignored on the consent page
36
+ const KNOWN_PARAMS = new Set([
37
+ 'response_type',
38
+ 'client_id',
39
+ 'redirect_uri',
40
+ 'scope',
41
+ 'state',
42
+ 'code_challenge',
43
+ 'code_challenge_method',
44
+ 'nonce',
45
+ 'login_hint',
46
+ 'prompt',
47
+ 'response_mode',
48
+ 'request',
49
+ 'request_uri'
50
+ ]);
51
+ const PENDING_TTL = 600 * 1000;
52
+ // how long an expired or revoked token is still known as such
53
+ const TOKEN_MEMORY = 24 * 3600 * 1000;
54
+ const randomToken = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
55
+ /**
56
+ * Reads the parameters of a request. RFC 6749 section 3.1: a parameter without a value counts as omitted, and
57
+ * one that is sent more than once is an error
58
+ *
59
+ * @param {URLSearchParams} input Query or form parameters
60
+ * @return {Object} `{ values, duplicates }`
61
+ */
62
+ function readParams(input) {
63
+ const values = Object.create(null);
64
+ const duplicates = new Set();
65
+ for (const [key, value] of input) {
66
+ if (value === '') {
67
+ continue;
68
+ }
69
+ if (key in values) {
70
+ duplicates.add(key);
71
+ }
72
+ values[key] = value;
73
+ }
74
+ return { values, duplicates: [...duplicates] };
75
+ }
76
+ /**
77
+ * Checks a registered redirect URI against the one a request sent
78
+ *
79
+ * @param {String} registered Registered URI, a trailing `*` matches any rest
80
+ * @param {String} given URI of the request
81
+ * @return {Boolean} true if they match
82
+ */
83
+ function redirectMatches(registered, given) {
84
+ const wildcard = registered.endsWith('*');
85
+ const base = wildcard ? registered.slice(0, -1) : registered;
86
+ const same = (a, b) => (wildcard ? a.startsWith(b) : a === b);
87
+ let baseUrl;
88
+ let givenUrl;
89
+ try {
90
+ baseUrl = new URL(base);
91
+ givenUrl = new URL(given);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ // RFC 8252 section 7.3: a loopback IP redirect URI matches with any port, the client picks it when it runs
97
+ if (baseUrl.protocol === 'http:' && isLoopback(baseUrl.hostname) && !baseUrl.port) {
98
+ return (givenUrl.protocol === 'http:' &&
99
+ givenUrl.hostname === baseUrl.hostname &&
100
+ same(givenUrl.pathname + givenUrl.search, baseUrl.pathname + baseUrl.search));
101
+ }
102
+ // RFC 6749 section 3.1.2.3: otherwise a simple string comparison
103
+ return same(given, base);
104
+ }
105
+ /**
106
+ * Checks the clients of the options
107
+ *
108
+ * @param {Object} clients Client ID to options
109
+ * @return {Map} client ID to client
110
+ */
111
+ function loadClients(clients) {
112
+ const result = new Map();
113
+ for (const id of Object.keys(clients)) {
114
+ result.set(id, makeClient(id, clients[id]));
115
+ }
116
+ return result;
117
+ }
118
+ /**
119
+ * Checks the options of a client
120
+ *
121
+ * @param {String} id Client ID
122
+ * @param {Object} options Client options
123
+ * @return {Object} client
124
+ * @throws {ImapKitError} INVALID for bad options
125
+ */
126
+ function makeClient(id, options) {
127
+ // RFC 6749 appendix A.1: client-id = *VSCHAR
128
+ if (typeof id !== 'string' || !/^[\x20-\x7e]+$/.test(id)) {
129
+ throw new ImapKitError('OAuth client ID must be a non-empty string of printable ASCII', 'INVALID');
130
+ }
131
+ const what = 'OAuth client ' + JSON.stringify(id);
132
+ if (!options || typeof options !== 'object') {
133
+ throw new ImapKitError(what + ' must be an object', 'INVALID');
134
+ }
135
+ const { name, secret, redirectUris, consent } = options;
136
+ if (name !== undefined && (typeof name !== 'string' || !name)) {
137
+ throw new ImapKitError(what + ': name must be a non-empty string', 'INVALID');
138
+ }
139
+ if (secret !== undefined && (typeof secret !== 'string' || !/^[\x20-\x7e]+$/.test(secret))) {
140
+ throw new ImapKitError(what + ': secret must be a non-empty string of printable ASCII', 'INVALID');
141
+ }
142
+ const uris = redirectUris === undefined ? LOOPBACK_REDIRECTS : redirectUris;
143
+ if (!Array.isArray(uris) || !uris.length) {
144
+ throw new ImapKitError(what + ': redirectUris must be a non-empty list', 'INVALID');
145
+ }
146
+ for (const uri of uris) {
147
+ const base = typeof uri === 'string' ? uri.replace(/\*$/, '') : '';
148
+ let valid = !!base && !base.includes('#');
149
+ try {
150
+ new URL(base);
151
+ }
152
+ catch {
153
+ valid = false;
154
+ }
155
+ if (!valid) {
156
+ // RFC 6749 section 3.1.2: an absolute URI without a fragment
157
+ throw new ImapKitError(what + ': redirect URI ' + JSON.stringify(uri) + ' must be an absolute URI without a fragment', 'INVALID');
158
+ }
159
+ }
160
+ return {
161
+ id,
162
+ name: name || id,
163
+ secret: secret === undefined ? null : secret,
164
+ redirectUris: uris.slice(),
165
+ consent: consent === undefined || consent === null ? null : checkOutcome(consent, what + ': consent')
166
+ };
167
+ }
168
+ /**
169
+ * Reads a positive number option
170
+ *
171
+ * @param {*} value Option value
172
+ * @param {Number} fallback Default
173
+ * @param {String} what Option name
174
+ * @param {Number} [max] Largest allowed value
175
+ * @return {Number} value
176
+ */
177
+ function positiveNumber(value, fallback, what, max = Infinity) {
178
+ if (value === undefined) {
179
+ return fallback;
180
+ }
181
+ if (typeof value !== 'number' || !(value > 0) || value > max) {
182
+ throw new Error(what + ' must be a positive number' + (max < Infinity ? ' of at most ' + max : ''));
183
+ }
184
+ return value;
185
+ }
186
+ class OAuthProvider {
187
+ constructor(server, options) {
188
+ this.server = server;
189
+ this.options = options && typeof options === 'object' ? options : {};
190
+ this.issuer = this.options.issuer ? this.checkIssuer(this.options.issuer) : '';
191
+ this.mailScope = this.options.mailScope === undefined ? 'mail' : this.options.mailScope;
192
+ if (typeof this.mailScope !== 'string' || !SCOPE_TOKEN.test(this.mailScope)) {
193
+ throw new Error('oauth.mailScope must be a scope token (RFC 6749 section 3.3)');
194
+ }
195
+ this.scopes = Object.assign({}, STANDARD_SCOPES, { [this.mailScope]: 'Read, send and manage your mail over IMAP and SMTP' });
196
+ for (const [name, description] of Object.entries(this.options.scopes || {})) {
197
+ if (!SCOPE_TOKEN.test(name) || typeof description !== 'string') {
198
+ throw new Error('oauth.scopes maps scope tokens (RFC 6749 section 3.3) to descriptions, ' + JSON.stringify(name) + ' is not one');
199
+ }
200
+ this.scopes[name] = description;
201
+ }
202
+ this.accessTokenTtl = positiveNumber(this.options.accessTokenTtl, 3600, 'oauth.accessTokenTtl');
203
+ this.codeTtl = positiveNumber(this.options.codeTtl, 60, 'oauth.codeTtl', 600);
204
+ const { unknownScopes, refreshTokens } = this.options;
205
+ if (unknownScopes !== undefined && unknownScopes !== 'refuse' && unknownScopes !== 'drop') {
206
+ throw new Error('oauth.unknownScopes must be "refuse" or "drop"');
207
+ }
208
+ if (refreshTokens !== undefined && refreshTokens !== 'offline_access' && refreshTokens !== 'always') {
209
+ throw new Error('oauth.refreshTokens must be "offline_access" or "always"');
210
+ }
211
+ this.queueCounter = 0;
212
+ // without the option, every provider shares one key, see defaultSigningKey()
213
+ this.key = this.options.signingKey ? loadSigningKey(this.options.signingKey) : null;
214
+ this.reset();
215
+ server.on('reset', () => this.reset());
216
+ this.registerControl();
217
+ }
218
+ /**
219
+ * Checks an issuer URL of the options: http or https, no query or fragment (RFC 8414 section 2)
220
+ *
221
+ * @param {String} issuer URL
222
+ * @return {String} the URL without a trailing slash
223
+ */
224
+ checkIssuer(issuer) {
225
+ let url = null;
226
+ try {
227
+ url = new URL(String(issuer));
228
+ }
229
+ catch {
230
+ // not a URL
231
+ }
232
+ if (!url || !/^https?:$/.test(url.protocol) || url.search || url.hash || typeof issuer !== 'string') {
233
+ throw new Error('oauth.issuer must be an http or https URL without a query or fragment');
234
+ }
235
+ return issuer.replace(/\/+$/, '');
236
+ }
237
+ /** Restores the clients and the default policy of the options and forgets every token, request and queue */
238
+ reset() {
239
+ this.clients = loadClients(this.options.clients || DEFAULT_CLIENTS);
240
+ this.consent = this.options.consent === undefined ? 'prompt' : checkOutcome(this.options.consent, 'oauth.consent');
241
+ this.decisions = [];
242
+ this.tokenFaults = [];
243
+ this.pending = new Map();
244
+ this.codes = new Map();
245
+ this.tokens = new Map();
246
+ }
247
+ /** Milliseconds since the epoch, from the `now` option */
248
+ nowMs() {
249
+ return this.server.now().getTime();
250
+ }
251
+ /**
252
+ * Sets the issuer from the address the HTTP server listens on, unless the `issuer` option gave one
253
+ *
254
+ * @param {String} host Address
255
+ * @param {Number} port Port
256
+ */
257
+ listening(host, port) {
258
+ if (!this.options.issuer) {
259
+ // a wildcard address is reached on the loopback address, the bundled certificate covers 127.0.0.1 too
260
+ const name = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host.includes(':') ? '[' + host + ']' : host;
261
+ this.issuer = (this.options.secure ? 'https' : 'http') + '://' + name + ':' + port;
262
+ }
263
+ }
264
+ /** The URL of the discovery document */
265
+ discoveryUrl() {
266
+ return this.issuer + '/.well-known/openid-configuration';
267
+ }
268
+ /** The users as the consent and index pages show them, the default user first */
269
+ pageUsers() {
270
+ return this.userNames().map(name => {
271
+ const data = this.server.getUser(name) || {};
272
+ return { name, displayName: data.displayName || undefined, consent: data.oauth && data.oauth.consent };
273
+ });
274
+ }
275
+ /**
276
+ * Marks a token as revoked or replaced by rotation
277
+ *
278
+ * @param {Object} record Token
279
+ * @param {String} how "revoked" or "rotated"
280
+ */
281
+ endToken(record, how) {
282
+ record[how] = true;
283
+ record.endedAt = record.endedAt === null ? this.nowMs() : record.endedAt;
284
+ }
285
+ /**
286
+ * Emits an `oauth` event, `{ type, ... }`, and logs it with the debug option
287
+ *
288
+ * @param {String} type Event type
289
+ * @param {Object} data Event data
290
+ */
291
+ emit(type, data) {
292
+ const event = Object.assign({ type }, data);
293
+ if (this.server.options.debug) {
294
+ console.log('[OAuth] %s %s', type, JSON.stringify(data));
295
+ }
296
+ this.server.emit('oauth', event);
297
+ }
298
+ // Authorization endpoint
299
+ /**
300
+ * Answers an authorization request (RFC 6749 section 4.1.1, OpenID Connect Core 1.0 section 3.1.2.1)
301
+ *
302
+ * @param {URLSearchParams} input Query (GET) or form (POST) parameters
303
+ * @return {Object} a redirect, a page, or a request for the consent page
304
+ */
305
+ authorize(input) {
306
+ this.prune();
307
+ const { values: p, duplicates } = readParams(input);
308
+ // RFC 6749 section 4.1.2.1: without a valid client and redirect URI there is nowhere safe to redirect to
309
+ if (duplicates.includes('client_id') || duplicates.includes('redirect_uri')) {
310
+ return this.errorPage('client_id or redirect_uri is sent more than once', 'Every parameter may only be sent once (RFC 6749 section 3.1).');
311
+ }
312
+ if (!p.client_id) {
313
+ return this.errorPage('The request has no client_id', 'Add the client_id of a registered client.', this.clientList());
314
+ }
315
+ const client = this.clients.get(p.client_id);
316
+ if (!client) {
317
+ return this.errorPage('Unknown client_id ' + JSON.stringify(p.client_id), 'This client is not registered.', this.clientList());
318
+ }
319
+ const redirect = this.resolveRedirect(client, p.redirect_uri);
320
+ if (typeof redirect !== 'string') {
321
+ return redirect;
322
+ }
323
+ const target = { redirectUri: redirect, state: p.state };
324
+ const fail = (error, description) => {
325
+ this.emit('authorize', { client: client.id, result: error, description });
326
+ return this.redirectError(target, error, description);
327
+ };
328
+ const request = this.checkRequest(client, redirect, p, duplicates, fail);
329
+ if ('type' in request) {
330
+ return request;
331
+ }
332
+ const { outcome, source } = this.decide(request);
333
+ this.emit('authorize', { client: client.id, user: request.hintUser || null, decision: describeOutcome(outcome), source, prompt: request.prompt });
334
+ return this.finish(request, outcome, source);
335
+ }
336
+ /**
337
+ * Checks the parameters of an authorization request after the client and the redirect URI
338
+ *
339
+ * @return {Object} the request, or the error redirect
340
+ */
341
+ checkRequest(client, redirectUri, p, duplicates, fail) {
342
+ if (duplicates.length) {
343
+ return fail('invalid_request', duplicates.join(', ') + ' sent more than once (RFC 6749 section 3.1)');
344
+ }
345
+ // OpenID Connect Core 1.0 sections 6.1 and 6.2
346
+ if (p.request) {
347
+ return fail('request_not_supported', 'Request objects are not supported (OpenID Connect Core 1.0 section 6.1)');
348
+ }
349
+ if (p.request_uri) {
350
+ return fail('request_uri_not_supported', 'request_uri is not supported (OpenID Connect Core 1.0 section 6.2)');
351
+ }
352
+ if (!p.response_type) {
353
+ return fail('invalid_request', 'response_type is missing (RFC 6749 section 4.1.1)');
354
+ }
355
+ if (p.response_type !== 'code') {
356
+ return fail('unsupported_response_type', 'Only the authorization code flow, response_type=code, is supported');
357
+ }
358
+ if (p.response_mode && p.response_mode !== 'query') {
359
+ return fail('invalid_request', 'Only response_mode=query is supported');
360
+ }
361
+ const checks = [];
362
+ const scope = this.checkScope(p.scope, checks);
363
+ if (typeof scope === 'string') {
364
+ return fail('invalid_scope', scope);
365
+ }
366
+ const pkce = this.checkPkce(client, p, checks);
367
+ if (pkce) {
368
+ return fail('invalid_request', pkce);
369
+ }
370
+ const prompt = p.prompt ? p.prompt.split(' ') : [];
371
+ const badPrompt = prompt.find(value => !PROMPT_VALUES.includes(value));
372
+ if (badPrompt !== undefined) {
373
+ return fail('invalid_request', 'Unknown prompt value ' + JSON.stringify(badPrompt) + ' (OpenID Connect Core 1.0 section 3.1.2.1)');
374
+ }
375
+ if (prompt.includes('none') && prompt.length > 1) {
376
+ return fail('invalid_request', 'prompt=none can not be combined with other values (OpenID Connect Core 1.0 section 3.1.2.1)');
377
+ }
378
+ checks.push(p.state
379
+ ? { level: 'ok', text: 'state is present (' + p.state.length + ' characters)' }
380
+ : { level: 'warn', text: 'no state: the client can not tell this redirect from a forged one (RFC 6749 section 10.12)' });
381
+ if (scope.includes('openid')) {
382
+ checks.push(p.nonce
383
+ ? { level: 'ok', text: 'nonce is present, the ID token carries it' }
384
+ : { level: 'note', text: 'no nonce: allowed for the code flow, the ID token carries none' });
385
+ }
386
+ const hintUser = p.login_hint === undefined ? undefined : this.server.getUser(p.login_hint) ? p.login_hint : null;
387
+ if (hintUser === null) {
388
+ checks.push({ level: 'warn', text: 'login_hint ' + JSON.stringify(p.login_hint) + ' is not a user of this server' });
389
+ }
390
+ else if (hintUser) {
391
+ checks.push({ level: 'ok', text: 'login_hint matches the user ' + hintUser });
392
+ }
393
+ return {
394
+ client,
395
+ redirectUri,
396
+ redirectUriSent: p.redirect_uri !== undefined,
397
+ state: p.state,
398
+ scope,
399
+ codeChallenge: p.code_challenge,
400
+ codeChallengeMethod: p.code_challenge ? p.code_challenge_method || 'plain' : undefined,
401
+ nonce: p.nonce,
402
+ loginHint: p.login_hint,
403
+ hintUser,
404
+ prompt,
405
+ checks,
406
+ ignored: Object.keys(p).filter(key => !KNOWN_PARAMS.has(key))
407
+ };
408
+ }
409
+ /**
410
+ * Finds the redirect URI of a request
411
+ *
412
+ * @return {String|Object} the URI, or the error page
413
+ */
414
+ resolveRedirect(client, given) {
415
+ const registered = client.redirectUris.map(uri => ' ' + uri).join('\n');
416
+ if (given === undefined) {
417
+ // RFC 6749 section 4.1.1: OPTIONAL only when the client has a single registered URI
418
+ const only = client.redirectUris.length === 1 && !client.redirectUris[0].endsWith('*') ? client.redirectUris[0] : null;
419
+ if (only) {
420
+ return only;
421
+ }
422
+ return this.errorPage('The request has no redirect_uri', 'Client ' + client.id + ' has more than one possible redirect URI, the request must name one.', ['Registered for ' + client.id + ':\n' + registered]);
423
+ }
424
+ if (given.includes('#')) {
425
+ return this.errorPage('redirect_uri has a fragment', 'A redirect URI must not include a fragment (RFC 6749 section 3.1.2).', [
426
+ 'Received: ' + given
427
+ ]);
428
+ }
429
+ if (!client.redirectUris.some(uri => redirectMatches(uri, given))) {
430
+ return this.errorPage('redirect_uri is not registered for ' + client.id, 'The authorization server must not redirect to it (RFC 6749 section 4.1.2.1).', ['Received: ' + given, 'Registered (a loopback http URI matches any port, RFC 8252 section 7.3):\n' + registered]);
431
+ }
432
+ return given;
433
+ }
434
+ /**
435
+ * Checks the scope parameter
436
+ *
437
+ * @return {Array|String} the granted scopes, or the error description
438
+ */
439
+ checkScope(value, checks) {
440
+ if (!value) {
441
+ // RFC 6749 section 3.3: without a default scope the server fails the request
442
+ return 'scope is missing, ImapKit has no default scope (RFC 6749 section 3.3)';
443
+ }
444
+ const requested = [...new Set(value.split(' ').filter(Boolean))];
445
+ const bad = requested.find(token => !SCOPE_TOKEN.test(token));
446
+ if (bad !== undefined || !requested.length) {
447
+ return 'Invalid scope ' + JSON.stringify(bad === undefined ? value : bad) + ' (RFC 6749 section 3.3)';
448
+ }
449
+ const unknown = requested.filter(token => !Object.hasOwn(this.scopes, token));
450
+ if (unknown.length && this.options.unknownScopes !== 'drop') {
451
+ return 'Unknown scope ' + unknown.join(' ') + ', known: ' + Object.keys(this.scopes).join(' ');
452
+ }
453
+ const scope = requested.filter(token => Object.hasOwn(this.scopes, token));
454
+ if (!scope.length) {
455
+ return 'None of the requested scopes is known, known: ' + Object.keys(this.scopes).join(' ');
456
+ }
457
+ checks.push(unknown.length
458
+ ? { level: 'warn', text: 'unknown scopes are left out of the grant: ' + unknown.join(' ') }
459
+ : { level: 'ok', text: 'every scope is known' });
460
+ if (!scope.includes(this.mailScope)) {
461
+ checks.push({ level: 'warn', text: 'the ' + this.mailScope + ' scope is missing: IMAP and SMTP will refuse the token' });
462
+ }
463
+ return scope;
464
+ }
465
+ /**
466
+ * Checks the PKCE parameters (RFC 7636 section 4.3, RFC 9700 section 2.1.1)
467
+ *
468
+ * @return {String|null} the error description, null if they are fine
469
+ */
470
+ checkPkce(client, p, checks) {
471
+ const challenge = p.code_challenge;
472
+ if (!challenge) {
473
+ if (p.code_challenge_method) {
474
+ return 'code_challenge_method without code_challenge (RFC 7636 section 4.3)';
475
+ }
476
+ if (!client.secret) {
477
+ return 'code_challenge is missing, public clients must use PKCE (RFC 9700 section 2.1.1)';
478
+ }
479
+ checks.push({ level: 'warn', text: 'no PKCE: confidential clients should use it too (RFC 9700 section 2.1.1)' });
480
+ return null;
481
+ }
482
+ // RFC 7636 section 4.3: the method defaults to plain
483
+ const method = p.code_challenge_method || 'plain';
484
+ if (method !== 'S256' && method !== 'plain') {
485
+ return 'Unknown code_challenge_method ' + JSON.stringify(method) + ' (RFC 7636 section 4.3)';
486
+ }
487
+ if (method === 'plain' && !this.options.allowPlainPkce) {
488
+ return ((p.code_challenge_method ? 'code_challenge_method=plain' : 'code_challenge_method is missing, which means plain,') +
489
+ ' but a client that can use S256 must use it (RFC 7636 section 4.2)');
490
+ }
491
+ if (!PKCE_VALUE.test(challenge) || (method === 'S256' && challenge.length !== 43)) {
492
+ return ('Invalid code_challenge, ' +
493
+ (method === 'S256' ? 'S256 gives 43 base64url characters' : 'it must be 43 to 128 unreserved characters') +
494
+ ' (RFC 7636 section 4.2)');
495
+ }
496
+ checks.push({ level: method === 'S256' ? 'ok' : 'warn', text: 'PKCE ' + method + ' code challenge' });
497
+ return null;
498
+ }
499
+ /**
500
+ * Finds the decision of a request: a queued decision, the user of login_hint, the client, the server default
501
+ *
502
+ * @param {Object} request Checked request
503
+ * @return {Object} `{ outcome, source }`
504
+ */
505
+ decide(request) {
506
+ const queued = this.decisions.find(item => (item.client === undefined || item.client === request.client.id) && (item.user === undefined || item.user === request.hintUser));
507
+ if (queued) {
508
+ if (--queued.remaining <= 0) {
509
+ this.decisions.splice(this.decisions.indexOf(queued), 1);
510
+ }
511
+ return { outcome: queued.decision, source: 'queued decision ' + queued.id };
512
+ }
513
+ const user = request.hintUser ? this.server.getUser(request.hintUser) : false;
514
+ const userConsent = user && user.oauth && user.oauth.consent;
515
+ if (userConsent) {
516
+ return { outcome: userConsent, source: 'user ' + request.hintUser };
517
+ }
518
+ if (request.client.consent) {
519
+ return { outcome: request.client.consent, source: 'client ' + request.client.id };
520
+ }
521
+ return { outcome: this.consent, source: 'server default' };
522
+ }
523
+ /**
524
+ * Carries out a decision
525
+ *
526
+ * @param {Object} request Checked request
527
+ * @param {String|Object} outcome Decision
528
+ * @param {String} source Where the decision came from
529
+ * @param {String} [user] The user picked on the consent page
530
+ * @return {Object} redirect, page or consent request
531
+ */
532
+ finish(request, outcome, source, user) {
533
+ const fail = (error, description) => this.redirectError(request, error, description);
534
+ if (outcome === 'prompt') {
535
+ if (request.prompt.includes('none')) {
536
+ // OpenID Connect Core 1.0 section 3.1.2.1: no page for prompt=none
537
+ return fail('consent_required', 'prompt=none, but the consent policy (' + source + ') shows the consent page');
538
+ }
539
+ const pending = Object.assign({}, request, { id: randomToken(18), expiresAt: this.nowMs() + PENDING_TTL, source });
540
+ this.pending.set(pending.id, pending);
541
+ return { type: 'consent', request: pending };
542
+ }
543
+ if (outcome === 'deny') {
544
+ return fail('access_denied', 'The sign-in was denied (' + source + ')');
545
+ }
546
+ if (typeof outcome === 'object') {
547
+ return fail(outcome.error, outcome.description || 'Answered by ' + source);
548
+ }
549
+ if (outcome === 'no-redirect') {
550
+ return {
551
+ type: 'page',
552
+ page: {
553
+ status: 200,
554
+ tone: 'info',
555
+ title: 'Not redirecting',
556
+ message: 'The decision (' +
557
+ source +
558
+ ') leaves this sign-in unanswered, so the client never gets its redirect. Use it to test how the client times out.'
559
+ }
560
+ };
561
+ }
562
+ const name = user || this.pickUser(request);
563
+ if (!name) {
564
+ return fail('access_denied', request.loginHint === undefined
565
+ ? 'The server has no users'
566
+ : 'login_hint ' + JSON.stringify(request.loginHint) + ' is not a user of this server');
567
+ }
568
+ const code = this.issueCode(request, name);
569
+ this.emit('code', { client: request.client.id, user: name, scope: request.scope.join(' '), source, fault: outcome === 'approve' ? null : outcome });
570
+ const params = { code };
571
+ if (request.state !== undefined || outcome === 'wrong-state') {
572
+ params.state = outcome === 'wrong-state' ? (request.state || '') + 'x-wrong-state' : request.state;
573
+ }
574
+ return { type: 'redirect', location: this.redirectUrl(request.redirectUri, params, outcome === 'wrong-iss') };
575
+ }
576
+ /**
577
+ * The user an approved request signs in: the user of login_hint, or the first user
578
+ *
579
+ * @return {String|null} user name, null if there is none
580
+ */
581
+ pickUser(request) {
582
+ if (request.loginHint !== undefined) {
583
+ return request.hintUser || null;
584
+ }
585
+ return this.userNames()[0] || null;
586
+ }
587
+ /** Sorted user names, the default user first */
588
+ userNames() {
589
+ return Object.keys(this.server.users).sort((a, b) => (a === 'testuser' ? -1 : b === 'testuser' ? 1 : a.localeCompare(b)));
590
+ }
591
+ /**
592
+ * Answers the form of the consent page
593
+ *
594
+ * @param {URLSearchParams} form Form fields: request, action (allow, deny, outcome), user, remember, outcome, description
595
+ * @return {Object} redirect or page
596
+ */
597
+ answerConsent(form) {
598
+ this.prune();
599
+ const id = form.get('request') || '';
600
+ const request = this.pending.get(id);
601
+ if (!request) {
602
+ return this.errorPage('This sign-in is no longer open', 'It was answered already, it expired after 10 minutes, or the server was reset or restarted. Start the sign-in again from the client.');
603
+ }
604
+ const action = form.get('action');
605
+ const user = form.get('user') || '';
606
+ if (action !== 'allow' && action !== 'deny' && action !== 'outcome') {
607
+ return this.errorPage('Unknown action', 'The form sent action ' + JSON.stringify(action) + '.');
608
+ }
609
+ if (action !== 'deny' && !this.server.getUser(user)) {
610
+ return this.errorPage('Choose an account', 'User ' + JSON.stringify(user) + ' does not exist. Go back and pick an account.');
611
+ }
612
+ let outcome = action === 'allow' ? 'approve' : 'deny';
613
+ if (action === 'outcome') {
614
+ const value = form.get('outcome') || '';
615
+ const description = form.get('description') || undefined;
616
+ try {
617
+ outcome = checkOutcome(value.startsWith('error:') ? { error: value.slice(6), description } : value, 'outcome', true);
618
+ }
619
+ catch (err) {
620
+ return this.errorPage('Unknown outcome', err.message);
621
+ }
622
+ }
623
+ this.pending.delete(id);
624
+ if (form.get('remember') === 'on' && (outcome === 'approve' || outcome === 'deny')) {
625
+ request.client.consent = outcome;
626
+ this.emit('remember', { client: request.client.id, consent: outcome });
627
+ }
628
+ this.emit('consent', { client: request.client.id, user: action === 'deny' ? null : user, decision: describeOutcome(outcome) });
629
+ return this.finish(request, outcome, 'consent page', user);
630
+ }
631
+ /**
632
+ * Drops expired consent requests and codes, and tokens a day after they expired or ended. Until then a login with
633
+ * one is refused as expired or revoked, after that as unknown
634
+ */
635
+ prune() {
636
+ const now = this.nowMs();
637
+ this.tokens.forEach((record, token) => {
638
+ const ended = record.endedAt !== null ? record.endedAt : record.expiresAt;
639
+ if (ended !== null && ended + TOKEN_MEMORY <= now) {
640
+ this.tokens.delete(token);
641
+ }
642
+ });
643
+ this.pending.forEach((request, id) => request.expiresAt <= now && this.pending.delete(id));
644
+ // an expired code is still known for a while, so its exchange fails as expired
645
+ this.codes.forEach((code, id) => code.expiresAt + PENDING_TTL <= now && this.codes.delete(id));
646
+ }
647
+ issueCode(request, user) {
648
+ const code = randomToken();
649
+ this.codes.set(code, {
650
+ clientId: request.client.id,
651
+ user,
652
+ redirectUri: request.redirectUri,
653
+ redirectUriSent: request.redirectUriSent,
654
+ scope: request.scope,
655
+ challenge: request.codeChallenge,
656
+ method: request.codeChallengeMethod,
657
+ nonce: request.nonce,
658
+ expiresAt: this.nowMs() + this.codeTtl * 1000,
659
+ authTime: Math.floor(this.nowMs() / 1000),
660
+ grantId: randomToken(12),
661
+ used: false
662
+ });
663
+ return code;
664
+ }
665
+ /**
666
+ * Builds a redirect to the client, with `iss` (RFC 9207 section 2)
667
+ *
668
+ * @param {String} redirectUri Redirect URI, its query is kept (RFC 6749 section 3.1.2)
669
+ * @param {Object} params Response parameters
670
+ * @param {Boolean} [wrongIss] true sends another issuer, the wrong-iss fault
671
+ * @return {String} URL
672
+ */
673
+ redirectUrl(redirectUri, params, wrongIss = false) {
674
+ const url = new URL(redirectUri);
675
+ for (const [key, value] of Object.entries(params)) {
676
+ url.searchParams.set(key, value);
677
+ }
678
+ url.searchParams.set('iss', wrongIss ? 'https://wrong-issuer.invalid' : this.issuer);
679
+ return url.toString();
680
+ }
681
+ redirectError(target, error, description) {
682
+ const params = { error, error_description: errorText(description) };
683
+ if (target.state !== undefined) {
684
+ // RFC 6749 section 4.1.2.1: REQUIRED if the request had it
685
+ params.state = target.state;
686
+ }
687
+ return { type: 'redirect', location: this.redirectUrl(target.redirectUri, params) };
688
+ }
689
+ errorPage(title, message, details) {
690
+ this.emit('authorize', { client: null, result: 'error page', description: title });
691
+ return { type: 'page', page: { status: 400, tone: 'error', title, message, details } };
692
+ }
693
+ clientList() {
694
+ return ['Registered clients:\n' + [...this.clients.values()].map(client => ' ' + client.id + ' (' + client.name + ')').join('\n')];
695
+ }
696
+ // Token endpoint
697
+ /**
698
+ * Answers a token request (RFC 6749 sections 4.1.3 and 6)
699
+ *
700
+ * @param {URLSearchParams} form Form parameters
701
+ * @param {String} [authorization] Authorization header
702
+ * @return {Promise<Object>} JSON answer
703
+ */
704
+ async token(form, authorization) {
705
+ this.prune();
706
+ const { values: p, duplicates } = readParams(form);
707
+ if (duplicates.length) {
708
+ return tokenError('invalid_request', duplicates.join(', ') + ' sent more than once (RFC 6749 section 3.2)');
709
+ }
710
+ const grant = p.grant_type;
711
+ const fault = this.takeTokenFault(grant);
712
+ if (fault && fault.delay) {
713
+ await new Promise(resolve => setTimeout(resolve, fault.delay));
714
+ }
715
+ if (fault && (fault.error || fault.html || fault.status)) {
716
+ this.emit('token', { grant: grant || null, result: 'fault', fault: fault.id });
717
+ if (fault.html) {
718
+ return { status: fault.status || 500, body: null, htmlError: true };
719
+ }
720
+ return tokenError(fault.error || 'server_error', fault.description || 'Queued token fault ' + fault.id, fault.status || 400);
721
+ }
722
+ const auth = this.authenticateClient(p, authorization);
723
+ if ('status' in auth) {
724
+ return auth;
725
+ }
726
+ const client = auth.client;
727
+ let result;
728
+ if (!grant) {
729
+ result = tokenError('invalid_request', 'grant_type is missing (RFC 6749 section 4.1.3)');
730
+ }
731
+ else if (grant === 'authorization_code') {
732
+ result = this.exchangeCode(client, p, fault);
733
+ }
734
+ else if (grant === 'refresh_token') {
735
+ result = this.refresh(client, p);
736
+ }
737
+ else {
738
+ result = tokenError('unsupported_grant_type', 'Supported grant types: authorization_code, refresh_token');
739
+ }
740
+ const body = result.body;
741
+ this.emit('token', { grant: grant || null, client: client.id, result: result.status === 200 ? 'ok' : body.error, description: body.error_description });
742
+ return result;
743
+ }
744
+ /**
745
+ * Authenticates the client of a token or revocation request (RFC 6749 section 2.3)
746
+ *
747
+ * @return {Object} `{ client }` or the error answer
748
+ */
749
+ authenticateClient(p, authorization) {
750
+ let id = p.client_id;
751
+ let secret = p.client_secret;
752
+ const basic = /^Basic +([A-Za-z0-9+/=]+)$/i.exec(authorization || '');
753
+ if (authorization && !basic) {
754
+ return tokenError('invalid_request', 'Only Basic authentication is supported in the Authorization header (RFC 6749 section 2.3.1)');
755
+ }
756
+ if (basic) {
757
+ if (secret !== undefined) {
758
+ return tokenError('invalid_request', 'The client used Basic authentication and client_secret, only one method is allowed (RFC 6749 section 2.3)');
759
+ }
760
+ const decoded = Buffer.from(basic[1], 'base64').toString('utf-8');
761
+ const colon = decoded.indexOf(':');
762
+ // RFC 6749 section 2.3.1: client ID and secret are form encoded before they go into the header
763
+ const decode = (value) => {
764
+ try {
765
+ return decodeURIComponent(value.replace(/\+/g, ' '));
766
+ }
767
+ catch {
768
+ return null;
769
+ }
770
+ };
771
+ const basicId = colon < 0 ? null : decode(decoded.slice(0, colon));
772
+ const basicSecret = colon < 0 ? null : decode(decoded.slice(colon + 1));
773
+ if (basicId === null || basicSecret === null) {
774
+ return tokenError('invalid_client', 'Malformed Basic credentials', 401, { 'WWW-Authenticate': 'Basic realm="imapkit"' });
775
+ }
776
+ if (id !== undefined && id !== basicId) {
777
+ return tokenError('invalid_request', 'client_id differs from the client of the Authorization header');
778
+ }
779
+ id = basicId;
780
+ secret = basicSecret;
781
+ }
782
+ const challenge = basic ? { 'WWW-Authenticate': 'Basic realm="imapkit"' } : undefined;
783
+ if (!id) {
784
+ return tokenError('invalid_client', 'No client authentication and no client_id (RFC 6749 section 4.1.3)', 401, challenge);
785
+ }
786
+ const client = this.clients.get(id);
787
+ if (!client) {
788
+ return tokenError('invalid_client', 'Unknown client ' + JSON.stringify(id), 401, challenge);
789
+ }
790
+ if (client.secret === null) {
791
+ if (secret !== undefined) {
792
+ return tokenError('invalid_client', 'Client ' + id + ' is a public client without a secret, leave client_secret out', 401, challenge);
793
+ }
794
+ return { client };
795
+ }
796
+ if (secret === undefined || !safeEqual(secret, client.secret)) {
797
+ return tokenError('invalid_client', secret === undefined ? 'Client ' + id + ' is confidential, it must send its secret' : 'Wrong client secret', 401, challenge);
798
+ }
799
+ return { client };
800
+ }
801
+ exchangeCode(client, p, fault) {
802
+ if (!p.code) {
803
+ return tokenError('invalid_request', 'code is missing (RFC 6749 section 4.1.3)');
804
+ }
805
+ const code = this.codes.get(p.code);
806
+ if (!code || code.expiresAt <= this.nowMs()) {
807
+ if (code) {
808
+ this.codes.delete(p.code);
809
+ }
810
+ return tokenError('invalid_grant', code ? 'The authorization code expired (' + this.codeTtl + ' seconds, RFC 6749 section 4.1.2)' : 'Unknown authorization code');
811
+ }
812
+ if (code.clientId !== client.id) {
813
+ return tokenError('invalid_grant', 'The authorization code was issued to another client (RFC 6749 section 4.1.3)');
814
+ }
815
+ if (code.used) {
816
+ // RFC 6749 section 4.1.2: a second use fails, and the tokens of the first use are revoked
817
+ const revoked = this.revokeGrant(code.grantId);
818
+ this.emit('revoke', { client: client.id, user: code.user, reason: 'authorization code reused', tokens: revoked });
819
+ return tokenError('invalid_grant', 'The authorization code was used already, its tokens are revoked now (RFC 6749 section 4.1.2)');
820
+ }
821
+ if (code.redirectUriSent ? p.redirect_uri !== code.redirectUri : p.redirect_uri !== undefined && p.redirect_uri !== code.redirectUri) {
822
+ return tokenError('invalid_grant', (p.redirect_uri === undefined ? 'redirect_uri is missing' : 'redirect_uri differs from the authorization request') +
823
+ ', it must be identical (RFC 6749 section 4.1.3)');
824
+ }
825
+ const verifier = p.code_verifier;
826
+ if (code.challenge) {
827
+ if (!verifier) {
828
+ return tokenError('invalid_grant', 'code_verifier is missing, the authorization request had a code_challenge (RFC 7636 section 4.5)');
829
+ }
830
+ if (!PKCE_VALUE.test(verifier)) {
831
+ return tokenError('invalid_grant', 'Invalid code_verifier, it must be 43 to 128 unreserved characters (RFC 7636 section 4.1)');
832
+ }
833
+ const computed = code.method === 'S256' ? crypto.createHash('sha256').update(verifier).digest('base64url') : verifier;
834
+ if (!safeEqual(computed, code.challenge)) {
835
+ return tokenError('invalid_grant', 'code_verifier does not match the code_challenge (RFC 7636 section 4.6)');
836
+ }
837
+ }
838
+ else if (verifier !== undefined) {
839
+ // RFC 9700 section 2.1.1: a code_verifier without a code_challenge is a PKCE downgrade
840
+ return tokenError('invalid_grant', 'code_verifier without a code_challenge in the authorization request (RFC 9700 section 2.1.1)');
841
+ }
842
+ if (!this.server.getUser(code.user)) {
843
+ return tokenError('invalid_grant', 'User ' + code.user + ' does not exist any more');
844
+ }
845
+ code.used = true;
846
+ return { status: 200, body: this.issueTokens(client, code.user, code.scope, code.grantId, { nonce: code.nonce, authTime: code.authTime, fault }) };
847
+ }
848
+ refresh(client, p) {
849
+ if (!p.refresh_token) {
850
+ return tokenError('invalid_request', 'refresh_token is missing (RFC 6749 section 6)');
851
+ }
852
+ const record = this.tokens.get(p.refresh_token);
853
+ if (!record || record.type !== 'refresh' || record.revoked) {
854
+ return tokenError('invalid_grant', record && record.revoked ? 'The refresh token was revoked' : 'Unknown refresh token');
855
+ }
856
+ if (record.clientId !== client.id) {
857
+ return tokenError('invalid_grant', 'The refresh token was issued to another client (RFC 6749 section 6)');
858
+ }
859
+ if (record.rotated) {
860
+ // RFC 9700 section 4.14.2: reuse of a rotated refresh token revokes the grant
861
+ const revoked = this.revokeGrant(record.grantId);
862
+ this.emit('revoke', { client: client.id, user: record.user, reason: 'rotated refresh token reused', tokens: revoked });
863
+ return tokenError('invalid_grant', 'This refresh token was replaced by rotation, using it again revoked the grant (RFC 9700 section 4.14.2)');
864
+ }
865
+ if (!this.server.getUser(record.user)) {
866
+ return tokenError('invalid_grant', 'User ' + record.user + ' does not exist any more');
867
+ }
868
+ let scope = record.scope;
869
+ if (p.scope) {
870
+ const requested = [...new Set(p.scope.split(' ').filter(Boolean))];
871
+ const extra = requested.filter(token => !record.scope.includes(token));
872
+ if (extra.length || !requested.length) {
873
+ // RFC 6749 section 6: no scope beyond the original grant
874
+ return tokenError('invalid_scope', 'Scope beyond the original grant: ' + (extra.join(' ') || p.scope) + ' (RFC 6749 section 6)');
875
+ }
876
+ scope = requested;
877
+ }
878
+ const body = this.issueTokens(client, record.user, scope, record.grantId, { refresh: record });
879
+ return { status: 200, body };
880
+ }
881
+ /**
882
+ * Issues an access token, and a refresh token and an ID token when the grant calls for them
883
+ *
884
+ * @return {Object} token response (RFC 6749 section 5.1)
885
+ */
886
+ issueTokens(client, user, scope, grantId, extra) {
887
+ const now = this.nowMs();
888
+ const accessToken = randomToken();
889
+ this.tokens.set(accessToken, {
890
+ token: accessToken,
891
+ type: 'access',
892
+ clientId: client.id,
893
+ user,
894
+ scope,
895
+ grantId,
896
+ expiresAt: now + this.accessTokenTtl * 1000,
897
+ revoked: false,
898
+ rotated: false,
899
+ endedAt: null
900
+ });
901
+ const body = { access_token: accessToken, token_type: 'Bearer', expires_in: this.accessTokenTtl, scope: scope.join(' ') };
902
+ if (extra.refresh) {
903
+ if (this.options.rotateRefreshTokens) {
904
+ this.endToken(extra.refresh, 'rotated');
905
+ body.refresh_token = this.newRefreshToken(client, user, extra.refresh.scope, grantId);
906
+ }
907
+ }
908
+ else if (this.options.refreshTokens === 'always' || scope.includes('offline_access')) {
909
+ // OpenID Connect Core 1.0 section 11: offline_access asks for a refresh token
910
+ body.refresh_token = this.newRefreshToken(client, user, scope, grantId);
911
+ }
912
+ if (!extra.refresh && scope.includes('openid')) {
913
+ body.id_token = this.idToken(client, user, scope, extra.nonce, extra.authTime, extra.fault ? extra.fault.claims : undefined);
914
+ }
915
+ return body;
916
+ }
917
+ newRefreshToken(client, user, scope, grantId) {
918
+ const token = randomToken();
919
+ this.tokens.set(token, {
920
+ token,
921
+ type: 'refresh',
922
+ clientId: client.id,
923
+ user,
924
+ scope,
925
+ grantId,
926
+ expiresAt: null,
927
+ revoked: false,
928
+ rotated: false,
929
+ endedAt: null
930
+ });
931
+ return token;
932
+ }
933
+ /**
934
+ * Signs an ID token (OpenID Connect Core 1.0 section 2). The user name is the email address
935
+ *
936
+ * @return {String} JWT
937
+ */
938
+ idToken(client, user, scope, nonce, authTime, changes) {
939
+ const iat = Math.floor(this.nowMs() / 1000);
940
+ const claims = Object.assign({ iss: this.issuer, sub: user, aud: client.id, azp: client.id, iat, exp: iat + this.accessTokenTtl, auth_time: authTime || iat }, nonce === undefined ? {} : { nonce }, this.userClaims(user, scope));
941
+ for (const [key, value] of Object.entries(changes || {})) {
942
+ if (value === null) {
943
+ delete claims[key];
944
+ }
945
+ else {
946
+ claims[key] = value;
947
+ }
948
+ }
949
+ return signJwt(this.key || defaultSigningKey(), claims);
950
+ }
951
+ /** The claims of the email and profile scopes */
952
+ userClaims(user, scope) {
953
+ const claims = {};
954
+ if (scope.includes('email')) {
955
+ claims.email = user;
956
+ claims.email_verified = true;
957
+ }
958
+ const data = this.server.getUser(user);
959
+ if (scope.includes('profile') && data && typeof data.displayName === 'string' && data.displayName) {
960
+ claims.name = data.displayName;
961
+ }
962
+ return claims;
963
+ }
964
+ /** The public key of the ID tokens (RFC 7517) */
965
+ jwks() {
966
+ return { keys: [(this.key || defaultSigningKey()).jwk] };
967
+ }
968
+ takeTokenFault(grant) {
969
+ const fault = this.tokenFaults.find(item => item.grant === undefined || item.grant === grant);
970
+ if (!fault) {
971
+ return null;
972
+ }
973
+ if (--fault.remaining <= 0) {
974
+ this.tokenFaults.splice(this.tokenFaults.indexOf(fault), 1);
975
+ }
976
+ return fault;
977
+ }
978
+ /**
979
+ * Revokes every token of a grant
980
+ *
981
+ * @return {Number} how many tokens were revoked
982
+ */
983
+ revokeGrant(grantId) {
984
+ let count = 0;
985
+ this.tokens.forEach(record => {
986
+ if (record.grantId === grantId && !record.revoked) {
987
+ this.endToken(record, 'revoked');
988
+ count++;
989
+ }
990
+ });
991
+ return count;
992
+ }
993
+ // Revocation and userinfo
994
+ /**
995
+ * Answers a revocation request (RFC 7009 section 2)
996
+ *
997
+ * @return {Object} JSON answer
998
+ */
999
+ revoke(form, authorization) {
1000
+ const { values: p, duplicates } = readParams(form);
1001
+ if (duplicates.length) {
1002
+ return tokenError('invalid_request', duplicates.join(', ') + ' sent more than once');
1003
+ }
1004
+ const auth = this.authenticateClient(p, authorization);
1005
+ if ('status' in auth) {
1006
+ return auth;
1007
+ }
1008
+ if (!p.token) {
1009
+ return tokenError('invalid_request', 'token is missing (RFC 7009 section 2.1)');
1010
+ }
1011
+ const record = this.tokens.get(p.token);
1012
+ if (!record) {
1013
+ // RFC 7009 section 2.2: an invalid token is not an error
1014
+ return { status: 200, body: {} };
1015
+ }
1016
+ if (record.clientId !== auth.client.id) {
1017
+ return tokenError('invalid_request', 'The token was issued to another client (RFC 7009 section 2.1)');
1018
+ }
1019
+ // RFC 7009 section 2.1: revoking a refresh token SHOULD also revoke the access tokens of the grant
1020
+ const count = record.type === 'refresh' ? this.revokeGrant(record.grantId) : Number(!record.revoked);
1021
+ this.endToken(record, 'revoked');
1022
+ this.emit('revoke', { client: auth.client.id, user: record.user, reason: record.type + ' token revoked by the client', tokens: count });
1023
+ return { status: 200, body: {} };
1024
+ }
1025
+ /**
1026
+ * Answers a UserInfo request (OpenID Connect Core 1.0 section 5.3)
1027
+ *
1028
+ * @param {String} [authorization] Authorization header with the access token
1029
+ * @return {Object} JSON answer
1030
+ */
1031
+ userinfo(authorization) {
1032
+ const bearer = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i.exec(authorization || '');
1033
+ const challenge = (error, description, status) => ({
1034
+ status,
1035
+ body: { error, error_description: description },
1036
+ // RFC 6750 section 3
1037
+ headers: { 'WWW-Authenticate': 'Bearer' + (error ? ' error="' + error + '", error_description="' + description + '"' : '') }
1038
+ });
1039
+ if (!bearer) {
1040
+ return challenge('invalid_request', 'Send the access token as Authorization: Bearer <token> (RFC 6750 section 2.1)', 401);
1041
+ }
1042
+ const record = this.tokens.get(bearer[1]);
1043
+ const reason = this.tokenProblem(record);
1044
+ if (reason || !record) {
1045
+ return challenge('invalid_token', 'The access token is ' + (reason || 'unknown'), 401);
1046
+ }
1047
+ if (!record.scope.includes('openid')) {
1048
+ return challenge('insufficient_scope', 'The access token has no openid scope', 403);
1049
+ }
1050
+ return { status: 200, body: Object.assign({ sub: record.user }, this.userClaims(record.user, record.scope)) };
1051
+ }
1052
+ /**
1053
+ * Why an access token can not be used
1054
+ *
1055
+ * @return {String|null} "expired", "revoked" ..., null for a usable token
1056
+ */
1057
+ tokenProblem(record) {
1058
+ if (!record || record.type !== 'access') {
1059
+ return record ? 'a refresh token, not an access token' : 'unknown';
1060
+ }
1061
+ if (record.revoked) {
1062
+ return 'revoked';
1063
+ }
1064
+ if (record.expiresAt !== null && record.expiresAt <= this.nowMs()) {
1065
+ return 'expired';
1066
+ }
1067
+ if (!this.server.getUser(record.user)) {
1068
+ return 'for a user that does not exist any more';
1069
+ }
1070
+ return null;
1071
+ }
1072
+ /**
1073
+ * Checks an access token for an IMAP or SMTP login
1074
+ *
1075
+ * @param {String} token Access token
1076
+ * @param {String} [username] The user the client logs in as, any if not set
1077
+ * @param {String} via "imap" or "smtp", for the event
1078
+ * @return {Object|null} `{ user }` or `{ error, reason }`, null for a token the provider did not issue
1079
+ */
1080
+ verifyAccessToken(token, username, via) {
1081
+ const record = this.tokens.get(token);
1082
+ if (!record || record.type !== 'access') {
1083
+ return null;
1084
+ }
1085
+ let result;
1086
+ const problem = this.tokenProblem(record);
1087
+ if (problem) {
1088
+ result = { error: 'invalid_token', reason: 'the access token is ' + problem };
1089
+ }
1090
+ else if (username !== undefined && username !== record.user) {
1091
+ result = { error: 'invalid_token', reason: 'the access token is for ' + record.user + ', not ' + username };
1092
+ }
1093
+ else if (!record.scope.includes(this.mailScope)) {
1094
+ result = { error: 'insufficient_scope', reason: 'the access token has no ' + this.mailScope + ' scope' };
1095
+ }
1096
+ else {
1097
+ result = { user: record.user };
1098
+ }
1099
+ this.emit('access', {
1100
+ via,
1101
+ client: record.clientId,
1102
+ user: record.user,
1103
+ result: 'user' in result ? 'ok' : result.error,
1104
+ reason: 'user' in result ? null : result.reason
1105
+ });
1106
+ return result;
1107
+ }
1108
+ /** The discovery document (OpenID Connect Discovery 1.0 section 3, RFC 8414 section 2) */
1109
+ metadata() {
1110
+ const authMethods = ['none', 'client_secret_basic', 'client_secret_post'];
1111
+ return {
1112
+ issuer: this.issuer,
1113
+ authorization_endpoint: this.issuer + '/authorize',
1114
+ token_endpoint: this.issuer + '/token',
1115
+ userinfo_endpoint: this.issuer + '/userinfo',
1116
+ jwks_uri: this.issuer + '/jwks',
1117
+ revocation_endpoint: this.issuer + '/revoke',
1118
+ scopes_supported: Object.keys(this.scopes),
1119
+ response_types_supported: ['code'],
1120
+ response_modes_supported: ['query'],
1121
+ grant_types_supported: ['authorization_code', 'refresh_token'],
1122
+ subject_types_supported: ['public'],
1123
+ id_token_signing_alg_values_supported: ['RS256'],
1124
+ token_endpoint_auth_methods_supported: authMethods,
1125
+ revocation_endpoint_auth_methods_supported: authMethods,
1126
+ code_challenge_methods_supported: this.options.allowPlainPkce ? ['S256', 'plain'] : ['S256'],
1127
+ claims_supported: ['iss', 'sub', 'aud', 'azp', 'exp', 'iat', 'auth_time', 'nonce', 'email', 'email_verified', 'name'],
1128
+ prompt_values_supported: PROMPT_VALUES,
1129
+ authorization_response_iss_parameter_supported: true,
1130
+ request_parameter_supported: false,
1131
+ request_uri_parameter_supported: false
1132
+ };
1133
+ }
1134
+ // Control API
1135
+ describeClient(client) {
1136
+ return { id: client.id, name: client.name, confidential: client.secret !== null, redirectUris: client.redirectUris.slice(), consent: client.consent };
1137
+ }
1138
+ describeToken(record) {
1139
+ return {
1140
+ type: record.type,
1141
+ token: record.token,
1142
+ user: record.user,
1143
+ client: record.clientId,
1144
+ scope: record.scope.join(' '),
1145
+ expiresAt: record.expiresAt === null ? null : new Date(record.expiresAt).toISOString(),
1146
+ expired: record.expiresAt !== null && record.expiresAt <= this.nowMs(),
1147
+ revoked: record.revoked,
1148
+ rotated: record.rotated
1149
+ };
1150
+ }
1151
+ /**
1152
+ * Selects tokens for the control API
1153
+ *
1154
+ * @param {Object} [filter] `{ user, client, token, type }`
1155
+ * @return {Array} token records
1156
+ */
1157
+ findTokens(filter) {
1158
+ const f = filter || {};
1159
+ optionalStrings(f, ['user', 'client', 'token']);
1160
+ if (f.type !== undefined && f.type !== 'access' && f.type !== 'refresh') {
1161
+ throw new ImapKitError('type must be "access" or "refresh"', 'INVALID');
1162
+ }
1163
+ return [...this.tokens.values()].filter(record => (f.user === undefined || record.user === f.user) &&
1164
+ (f.client === undefined || record.clientId === f.client) &&
1165
+ (f.token === undefined || record.token === f.token) &&
1166
+ (f.type === undefined || record.type === f.type));
1167
+ }
1168
+ queueDecision(options) {
1169
+ const o = options || {};
1170
+ const decision = checkOutcome(o.decision, 'decision', true);
1171
+ optionalStrings(o, ['client', 'user']);
1172
+ const id = ++this.queueCounter;
1173
+ this.decisions.push({ id, decision, client: o.client, user: o.user, remaining: countOption(o.count) });
1174
+ return { id };
1175
+ }
1176
+ queueTokenFault(options) {
1177
+ const o = options || {};
1178
+ const { grant, error, status, html, delay, claims } = o;
1179
+ optionalStrings(o, ['description']);
1180
+ if (grant !== undefined && grant !== 'authorization_code' && grant !== 'refresh_token') {
1181
+ throw new ImapKitError('grant must be "authorization_code" or "refresh_token"', 'INVALID');
1182
+ }
1183
+ if (error !== undefined && !isErrorCode(error)) {
1184
+ throw new ImapKitError('error must be an OAuth error code', 'INVALID');
1185
+ }
1186
+ if (status !== undefined && (!Number.isInteger(status) || status < 400 || status > 599)) {
1187
+ throw new ImapKitError('status must be an HTTP error status, 400 to 599', 'INVALID');
1188
+ }
1189
+ if (delay !== undefined && (typeof delay !== 'number' || !(delay >= 0) || delay > 600000)) {
1190
+ throw new ImapKitError('delay must be milliseconds, at most 600000', 'INVALID');
1191
+ }
1192
+ if (claims !== undefined && (!claims || typeof claims !== 'object' || Array.isArray(claims))) {
1193
+ throw new ImapKitError('claims must be an object, null values remove claims', 'INVALID');
1194
+ }
1195
+ if (error === undefined && !html && status === undefined && delay === undefined && claims === undefined) {
1196
+ throw new ImapKitError('A token fault needs error, status, html, delay or claims', 'INVALID');
1197
+ }
1198
+ const id = ++this.queueCounter;
1199
+ this.tokenFaults.push({
1200
+ id,
1201
+ grant: grant,
1202
+ error: error,
1203
+ description: o.description,
1204
+ status: status,
1205
+ html: !!html,
1206
+ delay: delay || 0,
1207
+ claims: claims,
1208
+ remaining: countOption(o.count)
1209
+ });
1210
+ return { id };
1211
+ }
1212
+ listClients() {
1213
+ return [...this.clients.values()].map(client => this.describeClient(client));
1214
+ }
1215
+ setClient(id, options) {
1216
+ const client = makeClient(id, options);
1217
+ this.clients.set(client.id, client);
1218
+ return this.describeClient(client);
1219
+ }
1220
+ deleteClient(id) {
1221
+ if (!this.clients.delete(id)) {
1222
+ throw new ImapKitError('OAuth client ' + JSON.stringify(id) + ' does not exist', 'NONEXISTENT');
1223
+ }
1224
+ }
1225
+ setConsent(consent) {
1226
+ this.consent = checkOutcome(consent, 'consent');
1227
+ return { consent: this.consent };
1228
+ }
1229
+ listTokens(filter) {
1230
+ return this.findTokens(filter).map(record => this.describeToken(record));
1231
+ }
1232
+ expireTokens(filter) {
1233
+ const records = this.findTokens(Object.assign({}, filter, { type: 'access' })).filter(record => !this.tokenProblem(record));
1234
+ const now = this.nowMs();
1235
+ records.forEach(record => (record.expiresAt = now));
1236
+ return { expired: records.length };
1237
+ }
1238
+ revokeTokens(filter) {
1239
+ const records = this.findTokens(filter).filter(record => !record.revoked);
1240
+ records.forEach(record => this.endToken(record, 'revoked'));
1241
+ if (records.length) {
1242
+ this.emit('revoke', { reason: 'control API', tokens: records.length });
1243
+ }
1244
+ return { revoked: records.length };
1245
+ }
1246
+ info() {
1247
+ return {
1248
+ issuer: this.issuer,
1249
+ discovery: this.issuer ? this.discoveryUrl() : null,
1250
+ consent: this.consent,
1251
+ mailScope: this.mailScope,
1252
+ scopes: Object.keys(this.scopes),
1253
+ clients: this.listClients(),
1254
+ decisions: this.decisions.map(item => Object.assign({}, item)),
1255
+ tokenFaults: this.tokenFaults.map(item => Object.assign({}, item))
1256
+ };
1257
+ }
1258
+ /** Adds the control operations and their REST routes, see README "Control API" */
1259
+ registerControl() {
1260
+ const control = this.server.control;
1261
+ const created = (body) => ({ status: 201, body });
1262
+ const deleted = () => ({ deleted: true });
1263
+ const tokenFilter = (query) => {
1264
+ const filter = {};
1265
+ for (const key of ['user', 'client', 'type']) {
1266
+ const value = query.get(key);
1267
+ if (value !== null) {
1268
+ filter[key] = value;
1269
+ }
1270
+ }
1271
+ return filter;
1272
+ };
1273
+ // name, operation, then the REST route: method, path, summary and how a request calls the operation
1274
+ const operations = [
1275
+ ['getOAuthInfo', () => this.info(), 'GET', '/v1/oauth', 'OAuth provider: issuer, default consent, clients, queues', () => control.getOAuthInfo()],
1276
+ [
1277
+ 'listOAuthClients',
1278
+ () => this.listClients(),
1279
+ 'GET',
1280
+ '/v1/oauth/clients',
1281
+ 'Registered OAuth clients, without secrets',
1282
+ () => control.listOAuthClients()
1283
+ ],
1284
+ [
1285
+ 'setOAuthClient',
1286
+ (id, options) => this.setClient(id, options),
1287
+ 'PUT',
1288
+ '/v1/oauth/clients/{id}',
1289
+ 'Registers or replaces an OAuth client ({ name, secret, redirectUris, consent })',
1290
+ ({ params, body }) => control.setOAuthClient(params.id, body)
1291
+ ],
1292
+ [
1293
+ 'deleteOAuthClient',
1294
+ (id) => this.deleteClient(id),
1295
+ 'DELETE',
1296
+ '/v1/oauth/clients/{id}',
1297
+ 'Removes an OAuth client',
1298
+ ({ params }) => (control.deleteOAuthClient(params.id), deleted())
1299
+ ],
1300
+ [
1301
+ 'setOAuthConsent',
1302
+ (consent) => this.setConsent(consent),
1303
+ 'PUT',
1304
+ '/v1/oauth/consent',
1305
+ 'Sets the default consent policy ({ consent }: prompt, approve, deny or { error, description })',
1306
+ ({ body }) => control.setOAuthConsent(body.consent)
1307
+ ],
1308
+ [
1309
+ 'queueOAuthDecision',
1310
+ (options) => this.queueDecision(options),
1311
+ 'POST',
1312
+ '/v1/oauth/decisions',
1313
+ 'Queues the decision of the next sign-ins ({ decision, client, user, count })',
1314
+ ({ body }) => created(control.queueOAuthDecision(body))
1315
+ ],
1316
+ [
1317
+ 'listOAuthDecisions',
1318
+ () => this.decisions.map(item => Object.assign({}, item)),
1319
+ 'GET',
1320
+ '/v1/oauth/decisions',
1321
+ 'Queued sign-in decisions',
1322
+ () => control.listOAuthDecisions()
1323
+ ],
1324
+ [
1325
+ 'clearOAuthDecisions',
1326
+ () => void (this.decisions = []),
1327
+ 'DELETE',
1328
+ '/v1/oauth/decisions',
1329
+ 'Removes the queued sign-in decisions',
1330
+ () => (control.clearOAuthDecisions(), deleted())
1331
+ ],
1332
+ [
1333
+ 'queueOAuthTokenFault',
1334
+ (options) => this.queueTokenFault(options),
1335
+ 'POST',
1336
+ '/v1/oauth/token-faults',
1337
+ 'Queues a fault of the token endpoint ({ grant, error, description, status, html, delay, claims, count })',
1338
+ ({ body }) => created(control.queueOAuthTokenFault(body))
1339
+ ],
1340
+ [
1341
+ 'clearOAuthTokenFaults',
1342
+ () => void (this.tokenFaults = []),
1343
+ 'DELETE',
1344
+ '/v1/oauth/token-faults',
1345
+ 'Removes the queued token endpoint faults',
1346
+ () => (control.clearOAuthTokenFaults(), deleted())
1347
+ ],
1348
+ [
1349
+ 'listOAuthTokens',
1350
+ (filter) => this.listTokens(filter),
1351
+ 'GET',
1352
+ '/v1/oauth/tokens',
1353
+ 'Issued tokens (?user=&client=&type=access|refresh to choose)',
1354
+ ({ query }) => control.listOAuthTokens(tokenFilter(query))
1355
+ ],
1356
+ [
1357
+ 'expireOAuthTokens',
1358
+ (filter) => this.expireTokens(filter),
1359
+ 'POST',
1360
+ '/v1/oauth/tokens/expire',
1361
+ 'Lets access tokens expire now ({ user, client, token })',
1362
+ ({ body }) => control.expireOAuthTokens(body)
1363
+ ],
1364
+ [
1365
+ 'revokeOAuthTokens',
1366
+ (filter) => this.revokeTokens(filter),
1367
+ 'POST',
1368
+ '/v1/oauth/tokens/revoke',
1369
+ 'Revokes access and refresh tokens ({ user, client, token, type })',
1370
+ ({ body }) => control.revokeOAuthTokens(body)
1371
+ ]
1372
+ ];
1373
+ for (const [name, fn, method, path, summary, handler] of operations) {
1374
+ control.register(name, fn, [{ method, path, summary, handler }]);
1375
+ }
1376
+ }
1377
+ }
1378
+ /**
1379
+ * Checks that the listed keys of an object are strings when they are set
1380
+ *
1381
+ * @param {Object} object Options
1382
+ * @param {Array} keys Keys to check
1383
+ * @throws {ImapKitError} INVALID for a value that is not a string
1384
+ */
1385
+ function optionalStrings(object, keys) {
1386
+ for (const key of keys) {
1387
+ if (object[key] !== undefined && typeof object[key] !== 'string') {
1388
+ throw new ImapKitError(key + ' must be a string', 'INVALID');
1389
+ }
1390
+ }
1391
+ }
1392
+ /**
1393
+ * Reads the count of a queued item
1394
+ *
1395
+ * @param {*} value Count, 1 if not set
1396
+ * @return {Number} count
1397
+ */
1398
+ function countOption(value) {
1399
+ if (value === undefined) {
1400
+ return 1;
1401
+ }
1402
+ if (!Number.isInteger(value) || value < 1) {
1403
+ throw new ImapKitError('count must be a positive integer', 'INVALID');
1404
+ }
1405
+ return value;
1406
+ }
1407
+ /**
1408
+ * An error answer of the token endpoint (RFC 6749 section 5.2)
1409
+ *
1410
+ * @return {Object} JSON answer
1411
+ */
1412
+ function tokenError(error, description, status = 400, headers) {
1413
+ return { status, body: { error, error_description: errorText(description) }, headers };
1414
+ }
1415
+ export { OAuthProvider, redirectMatches };