imapkit 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -21
- package/bin/help.txt +25 -1
- package/bin/imapkit.js +35 -0
- package/dist/browser/imapkit.js +99 -35
- package/dist/cjs/control.d.ts +16 -5
- package/dist/cjs/control.js +35 -4
- package/dist/cjs/oauth/http.d.ts +28 -0
- package/dist/cjs/oauth/http.js +216 -0
- package/dist/cjs/oauth/jwt.d.ts +33 -0
- package/dist/cjs/oauth/jwt.js +58 -0
- package/dist/cjs/oauth/pages.d.ts +47 -0
- package/dist/cjs/oauth/pages.js +310 -0
- package/dist/cjs/oauth/policy.d.ts +48 -0
- package/dist/cjs/oauth/policy.js +66 -0
- package/dist/cjs/oauth/provider.d.ts +455 -0
- package/dist/cjs/oauth/provider.js +1422 -0
- package/dist/cjs/plugins/oauthbearer.js +10 -15
- package/dist/cjs/plugins/xoauth2.js +4 -3
- package/dist/cjs/rest.d.ts +18 -1
- package/dist/cjs/rest.js +49 -31
- package/dist/cjs/server.d.ts +25 -1
- package/dist/cjs/server.js +73 -11
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +15 -3
- package/dist/cjs/types.d.ts +10 -0
- package/dist/esm/control.d.ts +16 -5
- package/dist/esm/control.js +35 -5
- package/dist/esm/oauth/http.d.ts +28 -0
- package/dist/esm/oauth/http.js +211 -0
- package/dist/esm/oauth/jwt.d.ts +33 -0
- package/dist/esm/oauth/jwt.js +50 -0
- package/dist/esm/oauth/pages.d.ts +47 -0
- package/dist/esm/oauth/pages.js +305 -0
- package/dist/esm/oauth/policy.d.ts +48 -0
- package/dist/esm/oauth/policy.js +59 -0
- package/dist/esm/oauth/provider.d.ts +455 -0
- package/dist/esm/oauth/provider.js +1415 -0
- package/dist/esm/plugins/oauthbearer.js +10 -15
- package/dist/esm/plugins/xoauth2.js +4 -3
- package/dist/esm/rest.d.ts +18 -1
- package/dist/esm/rest.js +48 -32
- package/dist/esm/server.d.ts +25 -1
- package/dist/esm/server.js +74 -12
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +15 -3
- package/dist/esm/types.d.ts +10 -0
- package/dist/plugin-help.json +2 -2
- package/package.json +6 -6
|
@@ -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
|
+
}
|