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,317 @@
1
+ "use strict";
2
+ /**
3
+ * The pages of the OAuth provider: the consent page, error and info pages, and the index page at `/`. They are a
4
+ * test tool's pages and say so (a striped "test server" ribbon, ImapKit's own mark, no password field), so that
5
+ * nobody takes them for a real provider's sign-in. They run no script and load nothing, src/oauth/http.ts sends
6
+ * them with a strict Content-Security-Policy.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.consentPage = consentPage;
10
+ exports.messagePage = messagePage;
11
+ exports.indexPage = indexPage;
12
+ const policy_js_1 = require("./policy.js");
13
+ const rest_js_1 = require("../rest.js");
14
+ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
15
+ const policyText = (policy) => (policy ? (0, policy_js_1.describeOutcome)(policy) : 'server default');
16
+ const STYLE = `
17
+ :root{color-scheme:light dark;--bg:#eef1f6;--card:#fff;--text:#1b2430;--muted:#5d6877;--line:#d9dfe8;--accent:#2457c5;--accent-text:#fff;--accent-soft:#e8eefc;--ok:#1a7f37;--ok-soft:#e6f4ea;--warn:#8a5a00;--warn-soft:#fff4d6;--bad:#b42318;--bad-soft:#fdecea;--code:#f0f2f5;--focus:#2457c5}
18
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--card:#161b22;--text:#e4e9f0;--muted:#9aa6b6;--line:#2b3440;--accent:#6e9bff;--accent-text:#0d1117;--accent-soft:#1b2640;--ok:#4cc06b;--ok-soft:#14281b;--warn:#e3b341;--warn-soft:#2d2411;--bad:#ff7b6e;--bad-soft:#331a18;--code:#1f252e;--focus:#8db0ff}}
19
+ *{box-sizing:border-box}
20
+ body{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;display:flex;justify-content:center;align-items:flex-start;padding:32px 16px}
21
+ main{width:100%;max-width:480px;background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;box-shadow:0 10px 30px rgba(16,32,64,.10)}
22
+ main.wide{max-width:760px}
23
+ .ribbon{background:repeating-linear-gradient(-45deg,#f5c542,#f5c542 10px,#efb429 10px,#efb429 20px);color:#3b2a00;font-weight:700;font-size:12px;letter-spacing:.05em;text-align:center;padding:5px 8px}
24
+ .body{padding:24px 26px 22px}
25
+ .brand{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px;margin:0 0 16px}
26
+ .logo{width:26px;height:26px;border-radius:7px;background:linear-gradient(#3a6fd8,#1d4597);color:#fff;font-weight:800;font-size:12px;display:grid;place-items:center;flex:none}
27
+ h1{font-size:20px;line-height:1.3;margin:0 0 6px}
28
+ h2{font-size:14px;margin:20px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
29
+ p{margin:0 0 12px}
30
+ .muted{color:var(--muted)}
31
+ .small{font-size:13px}
32
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow-wrap:anywhere}
33
+ code{background:var(--code);padding:1px 5px;border-radius:4px}
34
+ fieldset{border:0;margin:0 0 14px;padding:0}
35
+ legend{font-weight:600;margin:0 0 8px;padding:0}
36
+ .acct{display:flex;align-items:center;gap:11px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;margin-bottom:6px;cursor:pointer}
37
+ .acct:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}
38
+ .acct input{accent-color:var(--accent);width:16px;height:16px;margin:0;flex:none}
39
+ .av{width:30px;height:30px;border-radius:50%;color:#fff;display:grid;place-items:center;font-weight:700;font-size:13px;flex:none}
40
+ .who{min-width:0;flex:1}
41
+ .nm{font-weight:600;line-height:1.25;overflow-wrap:anywhere}
42
+ .em{font-size:12.5px;color:var(--muted);overflow-wrap:anywhere}
43
+ .tag{display:inline-block;font-size:11.5px;font-weight:600;padding:1px 8px;border-radius:999px;white-space:nowrap}
44
+ .tag.ok{background:var(--ok-soft);color:var(--ok)}.tag.warn{background:var(--warn-soft);color:var(--warn)}.tag.bad{background:var(--bad-soft);color:var(--bad)}.tag.info{background:var(--accent-soft);color:var(--accent)}.tag.note{background:var(--code);color:var(--muted)}
45
+ ul.scopes{list-style:none;padding:0;margin:0 0 12px;border:1px solid var(--line);border-radius:10px}
46
+ ul.scopes li{display:flex;gap:10px;padding:8px 12px;border-top:1px solid var(--line);align-items:baseline}
47
+ ul.scopes li:first-child{border-top:0}
48
+ .ck{color:var(--ok);font-weight:700}
49
+ ul.scopes li>span:nth-child(2){flex:1;min-width:0}
50
+ .raw{margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--muted);text-align:right;white-space:nowrap}
51
+ .redir{font-size:13px;color:var(--muted);margin:0 0 16px;overflow-wrap:anywhere}
52
+ .btns{display:flex;flex-direction:row-reverse;gap:10px;margin:0 0 12px}
53
+ button{flex:1;font:inherit;font-weight:600;padding:10px 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--text);cursor:pointer;text-align:center;text-decoration:none}
54
+ button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-text)}
55
+ button:hover{filter:brightness(1.05)}
56
+ button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible,a:focus-visible,.acct:has(input:focus-visible){outline:2px solid var(--focus);outline-offset:2px}
57
+ .remember{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--muted);margin:0 0 4px}
58
+ .remember input{accent-color:var(--accent)}
59
+ details{border-top:1px solid var(--line);padding:10px 0 0;margin-top:12px;font-size:13.5px}
60
+ summary{cursor:pointer;color:var(--accent);font-weight:600}
61
+ ul.checks{list-style:none;padding:0;margin:10px 0 6px}
62
+ ul.checks li{display:flex;gap:8px;padding:3px 0;align-items:baseline}
63
+ .kv{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:6px 0 0}
64
+ .row{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 0}
65
+ select,input[type=text]{font:inherit;font-size:13.5px;padding:7px 9px;border-radius:7px;border:1px solid var(--line);background:var(--card);color:var(--text);flex:1;min-width:0}
66
+ .row button{flex:none}
67
+ .mark{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-size:22px;font-weight:700;margin:0 0 14px}
68
+ .mark.error{background:var(--bad-soft);color:var(--bad)}.mark.info{background:var(--accent-soft);color:var(--accent)}
69
+ pre{background:var(--code);border-radius:8px;padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12.5px;margin:0 0 10px}
70
+ table{width:100%;border-collapse:collapse;font-size:13.5px;margin:0 0 6px}
71
+ th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
72
+ th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
73
+ footer{border-top:1px solid var(--line);padding:12px 26px;font-size:12.5px;color:var(--muted)}
74
+ footer a{color:var(--accent)}
75
+ @media (max-width:480px){body{padding:0}main{border-radius:0;border-left:0;border-right:0}.body{padding:20px 16px}footer{padding:12px 16px}}
76
+ `;
77
+ const AVATAR_COLORS = ['#7c5cf6', '#0e9f6e', '#d9480f', '#1c7ed6', '#c2255c', '#5c7cfa', '#2b8a3e'];
78
+ function avatar(name) {
79
+ let hash = 0;
80
+ for (const char of name) {
81
+ hash = (hash * 31 + (char.codePointAt(0) || 0)) >>> 0;
82
+ }
83
+ const letter = (name.match(/[\p{L}\p{N}]/u) || ['?'])[0].toUpperCase();
84
+ return '<span class="av" aria-hidden="true" style="background:' + AVATAR_COLORS[hash % AVATAR_COLORS.length] + '">' + escapeHtml(letter) + '</span>';
85
+ }
86
+ function layout(title, body, options = {}) {
87
+ return ('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
88
+ '<meta name="robots" content="noindex"><title>' +
89
+ escapeHtml(title) +
90
+ ' - ImapKit</title><style>' +
91
+ STYLE +
92
+ '</style></head><body><main' +
93
+ (options.wide ? ' class="wide"' : '') +
94
+ '><div class="ribbon" role="note">IMAPKIT TEST SERVER · NOT A REAL SIGN-IN</div><div class="body">' +
95
+ body +
96
+ '</div>' +
97
+ (options.footer ? '<footer>' + options.footer + '</footer>' : '') +
98
+ '</main></body></html>');
99
+ }
100
+ function brand(issuer) {
101
+ let host = issuer;
102
+ try {
103
+ host = new URL(issuer).host;
104
+ }
105
+ catch {
106
+ // the issuer is always a URL once the server listens
107
+ }
108
+ return '<div class="brand"><span class="logo" aria-hidden="true">IK</span><span>ImapKit sign-in · ' + escapeHtml(host) + '</span></div>';
109
+ }
110
+ // the levels are the tag classes
111
+ const checkTag = (check) => '<span class="tag ' + check.level + '">' + check.level + '</span>';
112
+ /** The outcomes of the "Other outcomes" section of the consent page */
113
+ const OTHER_OUTCOMES = [
114
+ ['error:access_denied', 'Return access_denied'],
115
+ ['error:invalid_scope', 'Return invalid_scope'],
116
+ ['error:server_error', 'Return server_error'],
117
+ ['error:temporarily_unavailable', 'Return temporarily_unavailable'],
118
+ ['error:consent_required', 'Return consent_required'],
119
+ ['error:login_required', 'Return login_required'],
120
+ ['wrong-state', 'Approve, but redirect with a wrong state'],
121
+ ['wrong-iss', 'Approve, but redirect with a wrong iss'],
122
+ ['no-redirect', 'Do not redirect (let the client time out)']
123
+ ];
124
+ /**
125
+ * The consent page: pick an account, Allow or Deny
126
+ *
127
+ * @param {Object} data `{ request, users, scopes, issuer }`
128
+ * @return {String} HTML
129
+ */
130
+ function consentPage(data) {
131
+ const { request, users, scopes, issuer } = data;
132
+ const client = request.client;
133
+ const selected = request.hintUser || (users[0] && users[0].name);
134
+ const checks = request.checks.slice();
135
+ if (selected && !selected.includes('@')) {
136
+ checks.push({
137
+ level: 'note',
138
+ text: 'user name ' + JSON.stringify(selected) + ' has no @: clients that take the email claim as an address may refuse it'
139
+ });
140
+ }
141
+ if (request.prompt.length) {
142
+ checks.push({
143
+ level: 'note',
144
+ text: 'prompt=' + request.prompt.join(' ') + ': the consent page is shown because the policy is prompt (' + request.source + ')'
145
+ });
146
+ }
147
+ if (request.ignored.length) {
148
+ checks.push({ level: 'note', text: 'ignored parameters: ' + request.ignored.join(', ') });
149
+ }
150
+ const passed = checks.filter(check => check.level === 'ok').length;
151
+ const warnings = checks.filter(check => check.level === 'warn').length;
152
+ const notes = checks.length - passed - warnings;
153
+ const hasWarning = checks.some(check => check.level === 'warn');
154
+ let target = request.redirectUri;
155
+ let loopback = false;
156
+ try {
157
+ const url = new URL(request.redirectUri);
158
+ target = url.origin !== 'null' ? url.origin + url.pathname : request.redirectUri;
159
+ loopback = url.protocol === 'http:' && (0, rest_js_1.isLoopback)(url.hostname);
160
+ }
161
+ catch {
162
+ // checked when the request came in
163
+ }
164
+ const accounts = users.length
165
+ ? users
166
+ .map(user => {
167
+ const badges = [
168
+ user.name === request.hintUser ? '<span class="tag info">login_hint</span>' : '',
169
+ user.consent
170
+ ? '<span class="tag ' + (user.consent === 'approve' ? 'ok' : 'bad') + '">auto: ' + escapeHtml(policyText(user.consent)) + '</span>'
171
+ : ''
172
+ ].join('');
173
+ return ('<label class="acct"><input type="radio" name="user" value="' +
174
+ escapeHtml(user.name) +
175
+ '"' +
176
+ (user.name === selected ? ' checked' : '') +
177
+ ' required>' +
178
+ avatar(user.displayName || user.name) +
179
+ '<span class="who"><span class="nm">' +
180
+ escapeHtml(user.displayName || user.name) +
181
+ '</span>' +
182
+ (user.displayName ? '<br><span class="em">' + escapeHtml(user.name) + '</span>' : '') +
183
+ '</span>' +
184
+ badges +
185
+ '</label>');
186
+ })
187
+ .join('')
188
+ : '<p class="muted">This server has no users, so only Deny is possible.</p>';
189
+ const scopeItems = request.scope
190
+ .map(scope => '<li><span class="ck" aria-hidden="true">✓</span><span>' +
191
+ escapeHtml(scopes[scope] || scope) +
192
+ '</span><span class="raw">' +
193
+ escapeHtml(scope) +
194
+ '</span></li>')
195
+ .join('');
196
+ const params = [
197
+ 'client_id=' + client.id,
198
+ 'response_type=code',
199
+ 'scope=' + request.scope.join(' '),
200
+ request.codeChallengeMethod ? 'code_challenge_method=' + request.codeChallengeMethod : '',
201
+ request.prompt.length ? 'prompt=' + request.prompt.join(' ') : '',
202
+ request.loginHint !== undefined ? 'login_hint=' + request.loginHint : ''
203
+ ].filter(Boolean);
204
+ const body = brand(issuer) +
205
+ '<h1>' +
206
+ escapeHtml(client.name) +
207
+ ' wants to access your mailbox</h1>' +
208
+ '<p class="muted small">Choose the account to sign in as. No password needed.' +
209
+ (client.name !== client.id ? ' Client ID <code>' + escapeHtml(client.id) + '</code>.' : '') +
210
+ '</p>' +
211
+ '<form method="post" action="/authorize/decision"><input type="hidden" name="request" value="' +
212
+ escapeHtml(request.id) +
213
+ '">' +
214
+ '<fieldset><legend>Account</legend>' +
215
+ accounts +
216
+ '</fieldset>' +
217
+ '<fieldset><legend>It will be able to</legend><ul class="scopes">' +
218
+ scopeItems +
219
+ '</ul></fieldset>' +
220
+ '<p class="redir">Returns to <strong>' +
221
+ escapeHtml(target) +
222
+ '</strong> ' +
223
+ (loopback ? '<span class="tag ok">loopback</span>' : '') +
224
+ '</p>' +
225
+ '<div class="btns">' +
226
+ (users.length ? '<button class="primary" type="submit" name="action" value="allow">Allow</button>' : '') +
227
+ '<button type="submit" name="action" value="deny" formnovalidate>Deny</button></div>' +
228
+ '<label class="remember"><input type="checkbox" name="remember" value="on"> Remember my choice for this app until the server restarts</label>' +
229
+ '<details' +
230
+ (hasWarning ? ' open' : '') +
231
+ '><summary>Request details (' +
232
+ passed +
233
+ ' passed' +
234
+ (warnings ? ', ' + warnings + (warnings === 1 ? ' warning' : ' warnings') : '') +
235
+ (notes ? ', ' + notes + (notes === 1 ? ' note' : ' notes') : '') +
236
+ ')</summary><ul class="checks">' +
237
+ checks.map(check => '<li>' + checkTag(check) + '<span>' + escapeHtml(check.text) + '</span></li>').join('') +
238
+ '</ul><p class="kv">' +
239
+ escapeHtml(params.join(' · ')) +
240
+ '</p><p class="kv">redirect_uri=' +
241
+ escapeHtml(request.redirectUri) +
242
+ '</p></details>' +
243
+ '<details><summary>Other outcomes</summary><p class="muted small" style="margin:8px 0 0">Answer this sign-in with an error or a broken redirect, to test how the client handles it.</p>' +
244
+ '<div class="row"><select id="outcome" name="outcome" aria-label="Outcome">' +
245
+ OTHER_OUTCOMES.map(([value, label]) => '<option value="' + value + '">' + escapeHtml(label) + '</option>').join('') +
246
+ '</select></div><div class="row"><input type="text" name="description" aria-label="error_description" placeholder="error_description (optional)" maxlength="300">' +
247
+ '<button type="submit" name="action" value="outcome" formnovalidate>Send</button></div></details>' +
248
+ '</form>';
249
+ return layout('Sign in to ' + client.name, body, {
250
+ footer: 'Shown because the sign-in policy is prompt (' + escapeHtml(request.source) + '). This request stays open for 10 minutes.'
251
+ });
252
+ }
253
+ /**
254
+ * An error or info page, instead of a redirect
255
+ *
256
+ * @param {Object} page Page data
257
+ * @param {String} issuer Issuer URL
258
+ * @return {String} HTML
259
+ */
260
+ function messagePage(page, issuer) {
261
+ const body = brand(issuer) +
262
+ '<div class="mark ' +
263
+ page.tone +
264
+ '" aria-hidden="true">' +
265
+ (page.tone === 'error' ? '!' : 'i') +
266
+ '</div><h1>' +
267
+ escapeHtml(page.title) +
268
+ '</h1><p class="muted">' +
269
+ escapeHtml(page.message) +
270
+ '</p>' +
271
+ (page.details || []).map(detail => '<pre>' + escapeHtml(detail) + '</pre>').join('');
272
+ return layout(page.title, body, {
273
+ footer: page.tone === 'error' ? 'The client was not redirected: there was no safe place to send the answer to.' : 'You can close this tab.'
274
+ });
275
+ }
276
+ /**
277
+ * The index page at `/`: issuer, clients and endpoints
278
+ *
279
+ * @param {Object} data `{ issuer, consent, clients, users, mailScope }`
280
+ * @return {String} HTML
281
+ */
282
+ function indexPage(data) {
283
+ const { issuer, clients, users } = data;
284
+ const endpoints = ['/.well-known/openid-configuration', '/authorize', '/token', '/userinfo', '/jwks', '/revoke'];
285
+ const body = brand(issuer) +
286
+ '<h1>ImapKit OAuth 2.0 / OpenID Connect provider</h1>' +
287
+ '<p class="muted">A test identity provider for mail clients. Tokens it issues log in to this ImapKit server over IMAP (XOAUTH2, OAUTHBEARER) and SMTP (AUTH XOAUTH2) with the <code>' +
288
+ escapeHtml(data.mailScope) +
289
+ '</code> scope. Default consent policy: <strong>' +
290
+ escapeHtml(policyText(data.consent)) +
291
+ '</strong>.</p>' +
292
+ '<h2>Issuer</h2><pre>' +
293
+ escapeHtml(issuer) +
294
+ '</pre>' +
295
+ '<h2>Clients</h2><table><thead><tr><th>Client ID</th><th>Name</th><th>Type</th><th>Consent</th></tr></thead><tbody>' +
296
+ clients
297
+ .map(client => '<tr><td><code>' +
298
+ escapeHtml(client.id) +
299
+ '</code></td><td>' +
300
+ escapeHtml(client.name) +
301
+ '</td><td>' +
302
+ (client.secret === null ? 'public' : 'confidential') +
303
+ '</td><td>' +
304
+ escapeHtml(policyText(client.consent)) +
305
+ '</td></tr>')
306
+ .join('') +
307
+ '</tbody></table>' +
308
+ '<h2>Users</h2><p class="small">' +
309
+ (users.length ? users.map(user => '<code>' + escapeHtml(user.name) + '</code>').join(' ') : 'none') +
310
+ '</p><h2>Endpoints</h2><p class="small">' +
311
+ endpoints.map(path => '<code>' + escapeHtml(path) + '</code>').join(' ') +
312
+ '</p>';
313
+ return layout('OAuth provider', body, {
314
+ wide: true,
315
+ footer: 'Documentation: <a href="https://imapkit.com/docs/guides/oauth" rel="noreferrer">imapkit.com/docs/guides/oauth</a>'
316
+ });
317
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Consent policies and outcomes of the OAuth provider, see README "OAuth 2.0 and OpenID Connect". A policy says
3
+ * what the authorization endpoint does with a sign-in: show the consent page (`prompt`), redirect with a code
4
+ * (`approve`), redirect with `access_denied` (`deny`), or redirect with another error. A queued decision can also
5
+ * break the redirect on purpose (`wrong-state`, `wrong-iss`, `no-redirect`) to test how a client copes.
6
+ */
7
+ /** An error outcome: the authorization response carries this error code and description */
8
+ export interface ErrorOutcome {
9
+ error: string;
10
+ description?: string | undefined;
11
+ }
12
+ /** What a user, a client or the server default answers */
13
+ export type ConsentPolicy = 'prompt' | 'approve' | 'deny' | ErrorOutcome;
14
+ /** Outcomes that break the redirect on purpose, only for queued decisions and the consent page */
15
+ export type FaultOutcome = 'wrong-state' | 'wrong-iss' | 'no-redirect';
16
+ /** Every outcome of a sign-in */
17
+ export type Outcome = ConsentPolicy | FaultOutcome;
18
+ /**
19
+ * Makes a text fit for error_description, other characters become spaces
20
+ *
21
+ * @param {String} text Description
22
+ * @return {String} the description
23
+ */
24
+ export declare const errorText: (text: string) => string;
25
+ /**
26
+ * Checks an OAuth error code
27
+ *
28
+ * @param {*} value Error code
29
+ * @return {Boolean} true for a non-empty string of the allowed characters
30
+ */
31
+ export declare const isErrorCode: (value: unknown) => value is string;
32
+ /**
33
+ * Checks a consent policy or an outcome
34
+ *
35
+ * @param {*} value Policy from the options or the control API
36
+ * @param {String} what Name of the setting, for the error message
37
+ * @param {Boolean} [faults] true allows the fault outcomes too
38
+ * @return {String|Object} the policy, an error outcome is copied
39
+ * @throws {ImapKitError} INVALID for anything else
40
+ */
41
+ export declare function checkOutcome(value: unknown, what: string, faults?: boolean): Outcome;
42
+ /**
43
+ * Describes an outcome for events and pages
44
+ *
45
+ * @param {String|Object} outcome Outcome
46
+ * @return {String} e.g. "approve" or "error invalid_scope"
47
+ */
48
+ export declare function describeOutcome(outcome: Outcome): string;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /**
3
+ * Consent policies and outcomes of the OAuth provider, see README "OAuth 2.0 and OpenID Connect". A policy says
4
+ * what the authorization endpoint does with a sign-in: show the consent page (`prompt`), redirect with a code
5
+ * (`approve`), redirect with `access_denied` (`deny`), or redirect with another error. A queued decision can also
6
+ * break the redirect on purpose (`wrong-state`, `wrong-iss`, `no-redirect`) to test how a client copes.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isErrorCode = exports.errorText = void 0;
10
+ exports.checkOutcome = checkOutcome;
11
+ exports.describeOutcome = describeOutcome;
12
+ const store_operations_js_1 = require("../store-operations.js");
13
+ // RFC 6749 section 4.1.2.1: error and error_description are %x20-21 / %x23-5B / %x5D-7E
14
+ const ERROR_TEXT = /^[\x20\x21\x23-\x5b\x5d-\x7e]*$/;
15
+ /**
16
+ * Makes a text fit for error_description, other characters become spaces
17
+ *
18
+ * @param {String} text Description
19
+ * @return {String} the description
20
+ */
21
+ const errorText = (text) => text.replace(/[^\x20\x21\x23-\x5b\x5d-\x7e]/g, ' ');
22
+ exports.errorText = errorText;
23
+ /**
24
+ * Checks an OAuth error code
25
+ *
26
+ * @param {*} value Error code
27
+ * @return {Boolean} true for a non-empty string of the allowed characters
28
+ */
29
+ const isErrorCode = (value) => typeof value === 'string' && value !== '' && ERROR_TEXT.test(value);
30
+ exports.isErrorCode = isErrorCode;
31
+ const POLICIES = ['prompt', 'approve', 'deny'];
32
+ const FAULTS = ['wrong-state', 'wrong-iss', 'no-redirect'];
33
+ /**
34
+ * Checks a consent policy or an outcome
35
+ *
36
+ * @param {*} value Policy from the options or the control API
37
+ * @param {String} what Name of the setting, for the error message
38
+ * @param {Boolean} [faults] true allows the fault outcomes too
39
+ * @return {String|Object} the policy, an error outcome is copied
40
+ * @throws {ImapKitError} INVALID for anything else
41
+ */
42
+ function checkOutcome(value, what, faults = false) {
43
+ if (typeof value === 'string' && (POLICIES.includes(value) || (faults && FAULTS.includes(value)))) {
44
+ return value;
45
+ }
46
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
47
+ const { error, description } = value;
48
+ if ((0, exports.isErrorCode)(error)) {
49
+ if (description !== undefined && (typeof description !== 'string' || !ERROR_TEXT.test(description))) {
50
+ throw new store_operations_js_1.ImapKitError(what + ': description must be printable ASCII without " and \\ (RFC 6749 section 4.1.2.1)', 'INVALID');
51
+ }
52
+ return description === undefined ? { error } : { error, description };
53
+ }
54
+ }
55
+ const allowed = POLICIES.concat(faults ? FAULTS : []).map(name => '"' + name + '"');
56
+ throw new store_operations_js_1.ImapKitError(what + ' must be ' + allowed.join(', ') + ' or { error, description }, e.g. { error: "access_denied" }', 'INVALID');
57
+ }
58
+ /**
59
+ * Describes an outcome for events and pages
60
+ *
61
+ * @param {String|Object} outcome Outcome
62
+ * @return {String} e.g. "approve" or "error invalid_scope"
63
+ */
64
+ function describeOutcome(outcome) {
65
+ return typeof outcome === 'string' ? outcome : 'error ' + outcome.error;
66
+ }