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.
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 +99 -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 +310 -0
  13. package/dist/cjs/oauth/policy.d.ts +48 -0
  14. package/dist/cjs/oauth/policy.js +66 -0
  15. package/dist/cjs/oauth/provider.d.ts +455 -0
  16. package/dist/cjs/oauth/provider.js +1422 -0
  17. package/dist/cjs/plugins/oauthbearer.js +10 -15
  18. package/dist/cjs/plugins/xoauth2.js +4 -3
  19. package/dist/cjs/rest.d.ts +18 -1
  20. package/dist/cjs/rest.js +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 +305 -0
  34. package/dist/esm/oauth/policy.d.ts +48 -0
  35. package/dist/esm/oauth/policy.js +59 -0
  36. package/dist/esm/oauth/provider.d.ts +455 -0
  37. package/dist/esm/oauth/provider.js +1415 -0
  38. package/dist/esm/plugins/oauthbearer.js +10 -15
  39. package/dist/esm/plugins/xoauth2.js +4 -3
  40. package/dist/esm/rest.d.ts +18 -1
  41. package/dist/esm/rest.js +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,28 @@
1
+ /**
2
+ * The HTTP server of the OAuth provider (`oauth` option, `--oauth-port`), on node:http or node:https only.
3
+ * Routes: `/` (index), `/.well-known/openid-configuration` and `/.well-known/oauth-authorization-server`
4
+ * (discovery), `/authorize` (GET and POST, OpenID Connect Core 1.0 section 3.1.2.1), `/authorize/decision` (the
5
+ * consent page form), `/token`, `/revoke`, `/userinfo` and `/jwks`.
6
+ *
7
+ * Bound to a loopback address (the default), it only answers requests for a loopback host name or the host of the
8
+ * `issuer` option, so a web page can not rebind its DNS name to it. The JSON endpoints allow cross-origin requests,
9
+ * like real providers do for browser-based clients; the pages are never framed and run no script.
10
+ */
11
+ import http from 'node:http';
12
+ import type { OAuthProvider } from './provider.js';
13
+ /**
14
+ * Creates the HTTP server of the provider, it does not listen yet
15
+ *
16
+ * @param {Object} provider OAuth provider
17
+ * @param {Object} options `{ secure, host, credentials }`, credentials are the TLS key and certificate of `secure`
18
+ * @return {Object} HTTP or HTTPS server
19
+ */
20
+ declare function createOAuthServer(provider: OAuthProvider, options: {
21
+ secure?: boolean | undefined;
22
+ host: string;
23
+ credentials: {
24
+ key: string | Buffer;
25
+ cert: string | Buffer;
26
+ };
27
+ }): http.Server;
28
+ export { createOAuthServer };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The HTTP server of the OAuth provider (`oauth` option, `--oauth-port`), on node:http or node:https only.
3
+ * Routes: `/` (index), `/.well-known/openid-configuration` and `/.well-known/oauth-authorization-server`
4
+ * (discovery), `/authorize` (GET and POST, OpenID Connect Core 1.0 section 3.1.2.1), `/authorize/decision` (the
5
+ * consent page form), `/token`, `/revoke`, `/userinfo` and `/jwks`.
6
+ *
7
+ * Bound to a loopback address (the default), it only answers requests for a loopback host name or the host of the
8
+ * `issuer` option, so a web page can not rebind its DNS name to it. The JSON endpoints allow cross-origin requests,
9
+ * like real providers do for browser-based clients; the pages are never framed and run no script.
10
+ */
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+ import { consentPage, indexPage, messagePage } from './pages.js';
14
+ import { isLoopback, readRaw } from '../rest.js';
15
+ // a form of a mail client sign-in is small, larger bodies are refused
16
+ const BODY_LIMIT = 64 * 1024;
17
+ class HttpError extends Error {
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
23
+ /**
24
+ * Reads an application/x-www-form-urlencoded body
25
+ *
26
+ * @param {Object} req HTTP request
27
+ * @return {Promise<URLSearchParams>} the form
28
+ */
29
+ async function readForm(req) {
30
+ // RFC 6749 sections 3.2 and 4.1.3: the parameters are form encoded
31
+ if (!/^application\/x-www-form-urlencoded\b/i.test(req.headers['content-type'] || '')) {
32
+ req.resume();
33
+ throw new HttpError(415, 'The body must be application/x-www-form-urlencoded (RFC 6749 section 4.1.3)');
34
+ }
35
+ const body = await readRaw(req, BODY_LIMIT, () => new HttpError(413, 'The body is larger than 64 KiB'));
36
+ return new URLSearchParams(body.toString('utf-8'));
37
+ }
38
+ const SECURITY_HEADERS = {
39
+ 'Cache-Control': 'no-store',
40
+ Pragma: 'no-cache',
41
+ 'X-Content-Type-Options': 'nosniff',
42
+ 'Referrer-Policy': 'no-referrer'
43
+ };
44
+ const CORS_HEADERS = {
45
+ 'Access-Control-Allow-Origin': '*',
46
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
47
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
48
+ };
49
+ /**
50
+ * The form-action sources of the consent page. Browsers check form-action on the redirect after the form too,
51
+ * so the origin of the client's redirect URI must be allowed
52
+ *
53
+ * @param {Object} request Pending request
54
+ * @return {String} CSP source list
55
+ */
56
+ function formActionSources(request) {
57
+ try {
58
+ const url = new URL(request.redirectUri);
59
+ return "'self' " + (url.origin !== 'null' ? url.origin : url.protocol);
60
+ }
61
+ catch {
62
+ return "'self'";
63
+ }
64
+ }
65
+ function sendHtml(res, status, html, formAction = "'none'") {
66
+ res.writeHead(status, {
67
+ ...SECURITY_HEADERS,
68
+ 'Content-Type': 'text/html; charset=utf-8',
69
+ 'Content-Length': Buffer.byteLength(html),
70
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action " + formAction,
71
+ 'X-Frame-Options': 'DENY'
72
+ });
73
+ res.end(html);
74
+ }
75
+ function sendJson(res, result) {
76
+ if (result.htmlError) {
77
+ // like a provider behind a broken proxy
78
+ sendHtml(res, result.status, '<!doctype html><title>Error</title><h1>Something went wrong</h1>');
79
+ return;
80
+ }
81
+ const json = JSON.stringify(result.body);
82
+ res.writeHead(result.status, {
83
+ ...SECURITY_HEADERS,
84
+ ...CORS_HEADERS,
85
+ ...(result.headers || {}),
86
+ 'Content-Type': 'application/json; charset=utf-8',
87
+ 'Content-Length': Buffer.byteLength(json)
88
+ });
89
+ res.end(json);
90
+ }
91
+ /**
92
+ * Checks the Origin of the consent form: the issuer, or the loopback name the page was opened on. A browser that
93
+ * sends no Origin, or "null" (Referrer-Policy: no-referrer), is let through; the request ID protects the form then
94
+ *
95
+ * @param {String} [origin] Origin header
96
+ * @param {String} issuer Issuer URL
97
+ * @param {String} [host] Host header
98
+ * @return {Boolean} true if the form may come from there
99
+ */
100
+ function sameSite(origin, issuer, host) {
101
+ if (!origin || origin === 'null' || origin === new URL(issuer).origin) {
102
+ return true;
103
+ }
104
+ try {
105
+ const url = new URL(origin);
106
+ return isLoopback(url.hostname) && url.host === host;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ /**
113
+ * Creates the HTTP server of the provider, it does not listen yet
114
+ *
115
+ * @param {Object} provider OAuth provider
116
+ * @param {Object} options `{ secure, host, credentials }`, credentials are the TLS key and certificate of `secure`
117
+ * @return {Object} HTTP or HTTPS server
118
+ */
119
+ function createOAuthServer(provider, options) {
120
+ const answerAuthorize = (res, result, status) => {
121
+ if (result.type === 'redirect') {
122
+ res.writeHead(status, { ...SECURITY_HEADERS, Location: result.location, 'Content-Type': 'text/plain; charset=utf-8' });
123
+ res.end('Redirecting to ' + result.location + '\n');
124
+ }
125
+ else if (result.type === 'page') {
126
+ sendHtml(res, result.page.status, messagePage(result.page, provider.issuer));
127
+ }
128
+ else {
129
+ const html = consentPage({ request: result.request, users: provider.pageUsers(), scopes: provider.scopes, issuer: provider.issuer });
130
+ sendHtml(res, 200, html, formActionSources(result.request));
131
+ }
132
+ };
133
+ const checkHost = (req) => {
134
+ if (!isLoopback(options.host)) {
135
+ return;
136
+ }
137
+ const host = (req.headers.host || '').replace(/:\d+$/, '');
138
+ let issuerHost = '';
139
+ try {
140
+ issuerHost = new URL(provider.issuer).hostname;
141
+ }
142
+ catch {
143
+ // no issuer yet
144
+ }
145
+ if (!isLoopback(host) && host.replace(/^\[|\]$/g, '') !== issuerHost.replace(/^\[|\]$/g, '')) {
146
+ // a page that rebinds its DNS name to 127.0.0.1 sends its own host name
147
+ throw new HttpError(421, 'Requests must use a loopback host name or the host of the issuer');
148
+ }
149
+ };
150
+ const handle = async (req, res) => {
151
+ checkHost(req);
152
+ const url = new URL(req.url || '/', 'http://localhost');
153
+ const method = (req.method || 'GET').toUpperCase();
154
+ const route = method + ' ' + url.pathname;
155
+ if (method === 'OPTIONS') {
156
+ res.writeHead(204, { ...CORS_HEADERS, 'Access-Control-Max-Age': '600' });
157
+ return res.end();
158
+ }
159
+ switch (route) {
160
+ case 'GET /':
161
+ return sendHtml(res, 200, indexPage({
162
+ issuer: provider.issuer,
163
+ consent: provider.consent,
164
+ clients: [...provider.clients.values()],
165
+ users: provider.pageUsers(),
166
+ mailScope: provider.mailScope
167
+ }));
168
+ case 'GET /.well-known/openid-configuration':
169
+ case 'GET /.well-known/oauth-authorization-server':
170
+ return sendJson(res, { status: 200, body: provider.metadata() });
171
+ case 'GET /jwks':
172
+ return sendJson(res, { status: 200, body: provider.jwks() });
173
+ case 'GET /authorize':
174
+ return answerAuthorize(res, provider.authorize(url.searchParams), 302);
175
+ case 'POST /authorize':
176
+ // RFC 9700 section 4.12: 303 turns the POST into a GET, never 307
177
+ return answerAuthorize(res, provider.authorize(await readForm(req)), 303);
178
+ case 'POST /authorize/decision': {
179
+ if (!sameSite(req.headers.origin, provider.issuer, req.headers.host)) {
180
+ throw new HttpError(403, 'The consent form must come from the consent page');
181
+ }
182
+ return answerAuthorize(res, provider.answerConsent(await readForm(req)), 303);
183
+ }
184
+ case 'POST /token':
185
+ return sendJson(res, await provider.token(await readForm(req), req.headers.authorization));
186
+ case 'POST /revoke':
187
+ return sendJson(res, provider.revoke(await readForm(req), req.headers.authorization));
188
+ case 'GET /userinfo':
189
+ return sendJson(res, provider.userinfo(req.headers.authorization));
190
+ case 'POST /userinfo':
191
+ req.resume();
192
+ return sendJson(res, provider.userinfo(req.headers.authorization));
193
+ default: {
194
+ const known = ['/', '/authorize', '/authorize/decision', '/token', '/revoke', '/userinfo', '/jwks'];
195
+ const wellKnown = url.pathname.startsWith('/.well-known/');
196
+ throw new HttpError(known.includes(url.pathname) || wellKnown ? 405 : 404, known.includes(url.pathname) ? 'Method not allowed' : 'Not found');
197
+ }
198
+ }
199
+ };
200
+ const listener = (req, res) => {
201
+ handle(req, res).catch((err) => {
202
+ const status = err instanceof HttpError ? err.status : 500;
203
+ if (!res.headersSent) {
204
+ // the token and revocation endpoints answer errors in JSON (RFC 6749 section 5.2)
205
+ sendJson(res, { status, body: { error: status === 500 ? 'server_error' : 'invalid_request', error_description: err.message } });
206
+ }
207
+ });
208
+ };
209
+ return options.secure ? https.createServer(options.credentials, listener) : http.createServer(listener);
210
+ }
211
+ export { createOAuthServer };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Signs the ID tokens of the OAuth provider: RS256 JSON Web Tokens (RFC 7519, RFC 7515) and the JWK Set of the
3
+ * public key (RFC 7517), with the RFC 7638 thumbprint as the key ID
4
+ */
5
+ import crypto from 'node:crypto';
6
+ /** An RSA signing key with its public JWK */
7
+ export interface SigningKey {
8
+ privateKey: crypto.KeyObject;
9
+ jwk: Record<string, string>;
10
+ kid: string;
11
+ }
12
+ /**
13
+ * Loads or makes the signing key
14
+ *
15
+ * @param {String|Buffer} [pem] PKCS#8 or PKCS#1 private key in PEM, a new 2048 bit key if not set (see defaultSigningKey())
16
+ * @return {Object} the key, its public JWK and key ID
17
+ * @throws {Error} for a key that is not RSA
18
+ */
19
+ export declare function loadSigningKey(pem?: string | Buffer): SigningKey;
20
+ /**
21
+ * The shared signing key of the providers without the signingKey option, made when the first one needs it
22
+ *
23
+ * @return {Object} signing key
24
+ */
25
+ export declare function defaultSigningKey(): SigningKey;
26
+ /**
27
+ * Signs a JWT with RS256
28
+ *
29
+ * @param {Object} key Signing key
30
+ * @param {Object} claims JWT claims
31
+ * @return {String} compact serialization
32
+ */
33
+ export declare function signJwt(key: SigningKey, claims: Record<string, unknown>): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Signs the ID tokens of the OAuth provider: RS256 JSON Web Tokens (RFC 7519, RFC 7515) and the JWK Set of the
3
+ * public key (RFC 7517), with the RFC 7638 thumbprint as the key ID
4
+ */
5
+ import crypto from 'node:crypto';
6
+ const base64url = (data) => Buffer.from(data).toString('base64url');
7
+ /**
8
+ * Loads or makes the signing key
9
+ *
10
+ * @param {String|Buffer} [pem] PKCS#8 or PKCS#1 private key in PEM, a new 2048 bit key if not set (see defaultSigningKey())
11
+ * @return {Object} the key, its public JWK and key ID
12
+ * @throws {Error} for a key that is not RSA
13
+ */
14
+ export function loadSigningKey(pem) {
15
+ const privateKey = pem ? crypto.createPrivateKey(pem) : crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
16
+ if (privateKey.asymmetricKeyType !== 'rsa') {
17
+ throw new Error('oauth.signingKey must be an RSA private key, ID tokens are signed with RS256');
18
+ }
19
+ const { n, e } = crypto.createPublicKey(privateKey).export({ format: 'jwk' });
20
+ // RFC 7638 section 3.2: the required members in lexicographic order, no white space
21
+ const kid = base64url(crypto
22
+ .createHash('sha256')
23
+ .update(JSON.stringify({ e, kty: 'RSA', n }))
24
+ .digest());
25
+ return { privateKey, kid, jwk: { kty: 'RSA', use: 'sig', alg: 'RS256', kid, n, e } };
26
+ }
27
+ // the key of every provider without a signingKey option, made once: a 2048 bit RSA key takes a moment
28
+ let defaultKey = null;
29
+ /**
30
+ * The shared signing key of the providers without the signingKey option, made when the first one needs it
31
+ *
32
+ * @return {Object} signing key
33
+ */
34
+ export function defaultSigningKey() {
35
+ if (!defaultKey) {
36
+ defaultKey = loadSigningKey();
37
+ }
38
+ return defaultKey;
39
+ }
40
+ /**
41
+ * Signs a JWT with RS256
42
+ *
43
+ * @param {Object} key Signing key
44
+ * @param {Object} claims JWT claims
45
+ * @return {String} compact serialization
46
+ */
47
+ export function signJwt(key, claims) {
48
+ const input = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: key.kid })) + '.' + base64url(JSON.stringify(claims));
49
+ return input + '.' + base64url(crypto.sign('sha256', Buffer.from(input), key.privateKey));
50
+ }
@@ -0,0 +1,47 @@
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 type { OAuthClient, PageData, PendingRequest } from './provider.js';
8
+ import type { ConsentPolicy } from './policy.js';
9
+ /** A user on the consent page */
10
+ export interface PageUser {
11
+ name: string;
12
+ displayName: string | undefined;
13
+ consent: ConsentPolicy | undefined;
14
+ }
15
+ /**
16
+ * The consent page: pick an account, Allow or Deny
17
+ *
18
+ * @param {Object} data `{ request, users, scopes, issuer }`
19
+ * @return {String} HTML
20
+ */
21
+ export declare function consentPage(data: {
22
+ request: PendingRequest;
23
+ users: PageUser[];
24
+ scopes: Record<string, string>;
25
+ issuer: string;
26
+ }): string;
27
+ /**
28
+ * An error or info page, instead of a redirect
29
+ *
30
+ * @param {Object} page Page data
31
+ * @param {String} issuer Issuer URL
32
+ * @return {String} HTML
33
+ */
34
+ export declare function messagePage(page: PageData, issuer: string): string;
35
+ /**
36
+ * The index page at `/`: issuer, clients and endpoints
37
+ *
38
+ * @param {Object} data `{ issuer, consent, clients, users, mailScope }`
39
+ * @return {String} HTML
40
+ */
41
+ export declare function indexPage(data: {
42
+ issuer: string;
43
+ consent: ConsentPolicy;
44
+ clients: OAuthClient[];
45
+ users: PageUser[];
46
+ mailScope: string;
47
+ }): string;