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