imapkit 5.2.0 → 5.3.1

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