imapkit 5.1.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 +41 -23
- package/bin/help.txt +29 -3
- package/bin/imapkit.js +43 -0
- package/dist/browser/imapkit.js +99 -35
- package/dist/cjs/control.d.ts +16 -5
- package/dist/cjs/control.js +36 -5
- 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 +63 -35
- package/dist/cjs/server.d.ts +49 -1
- package/dist/cjs/server.js +126 -12
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +15 -3
- package/dist/cjs/types.d.ts +16 -0
- package/dist/esm/control.d.ts +16 -5
- package/dist/esm/control.js +36 -6
- 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 +62 -36
- package/dist/esm/server.d.ts +49 -1
- package/dist/esm/server.js +127 -13
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +15 -3
- package/dist/esm/types.d.ts +16 -0
- package/dist/plugin-help.json +2 -2
- package/package.json +7 -7
package/dist/cjs/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { IMAPServer, IMAPConnection } from './server.js';
|
|
2
2
|
import type { ScriptRule } from './script.js';
|
|
3
|
+
import type { OAuthOptions } from './oauth/provider.js';
|
|
4
|
+
import type { ConsentPolicy } from './oauth/policy.js';
|
|
3
5
|
export type { IMAPServer, IMAPConnection };
|
|
4
6
|
/**
|
|
5
7
|
* A value of the parsed command or of a response, as imap-handler parses and compiles it: an
|
|
@@ -210,6 +212,12 @@ export interface MailboxStatus {
|
|
|
210
212
|
}
|
|
211
213
|
export interface UserData {
|
|
212
214
|
password?: string | undefined;
|
|
215
|
+
/** the display name, the `name` claim of the OAuth provider */
|
|
216
|
+
displayName?: string | undefined;
|
|
217
|
+
/** OAuth provider settings of the user: the consent policy of sign-ins that name the user in login_hint */
|
|
218
|
+
oauth?: {
|
|
219
|
+
consent?: ConsentPolicy | undefined;
|
|
220
|
+
} | undefined;
|
|
213
221
|
xoauth2?: {
|
|
214
222
|
accessToken?: string | undefined;
|
|
215
223
|
/**
|
|
@@ -228,6 +236,12 @@ export interface IMAPServerOptions {
|
|
|
228
236
|
plugins?: (string | Plugin)[] | string | Plugin | undefined;
|
|
229
237
|
users?: Record<string, UserData> | undefined;
|
|
230
238
|
secureConnection?: boolean | undefined;
|
|
239
|
+
/** starts a second IMAP listener for implicit TLS (IMAPS) with `server.start()`, its sessions share the storage
|
|
240
|
+
* with the main listener. Without a host it listens on the host given to `start()` */
|
|
241
|
+
tls?: {
|
|
242
|
+
port?: number | undefined;
|
|
243
|
+
host?: string | undefined;
|
|
244
|
+
} | undefined;
|
|
231
245
|
/** starts an SMTP server with `server.start()` that appends every received message to INBOX, needs the optional
|
|
232
246
|
* smtp-server package */
|
|
233
247
|
smtp?: {
|
|
@@ -240,6 +254,8 @@ export interface IMAPServerOptions {
|
|
|
240
254
|
host?: string | undefined;
|
|
241
255
|
token?: string | undefined;
|
|
242
256
|
} | undefined;
|
|
257
|
+
/** starts the OAuth 2.0 / OpenID Connect provider with `server.start()`, see src/oauth/provider.ts */
|
|
258
|
+
oauth?: OAuthOptions | undefined;
|
|
243
259
|
credentials?: {
|
|
244
260
|
key: string | Buffer;
|
|
245
261
|
cert: string | Buffer;
|
package/dist/esm/control.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { ImapKitError } from './store-operations.js';
|
|
10
10
|
import type { FlagMode, UidValidityOptions } from './store-operations.js';
|
|
11
|
+
import type { ConsentPolicy } from './oauth/policy.js';
|
|
11
12
|
import type { IMAPConnection, IMAPServer, Mailbox, Message, StorageMailbox, StorageNamespace, UserData } from './types.js';
|
|
12
13
|
/** A mailbox as `getMailbox()` and `listMailboxes()` describe it */
|
|
13
14
|
interface MailboxInfo {
|
|
@@ -68,7 +69,7 @@ interface NewMessage {
|
|
|
68
69
|
internaldate?: Date | string | undefined;
|
|
69
70
|
}
|
|
70
71
|
/** The credentials of a user, see UserData */
|
|
71
|
-
type UserOptions = Pick<UserData, 'password' | 'xoauth2'>;
|
|
72
|
+
type UserOptions = Pick<UserData, 'password' | 'xoauth2' | 'displayName' | 'oauth'>;
|
|
72
73
|
/** What a REST route handler gets, see src/rest.ts */
|
|
73
74
|
interface RouteRequest {
|
|
74
75
|
params: Record<string, string>;
|
|
@@ -89,6 +90,16 @@ type SessionFilter = number | {
|
|
|
89
90
|
session?: number | undefined;
|
|
90
91
|
user?: string | undefined;
|
|
91
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Checks the `oauth` settings of a user
|
|
95
|
+
*
|
|
96
|
+
* @param {*} value `{ consent }`
|
|
97
|
+
* @param {String} what Setting name for the error message
|
|
98
|
+
* @return {Object} a copy
|
|
99
|
+
*/
|
|
100
|
+
declare function checkUserOAuth(value: unknown, what: string): {
|
|
101
|
+
consent?: ConsentPolicy;
|
|
102
|
+
};
|
|
92
103
|
declare class Control {
|
|
93
104
|
[key: string]: any;
|
|
94
105
|
server: IMAPServer;
|
|
@@ -364,14 +375,14 @@ declare class Control {
|
|
|
364
375
|
* Adds a user
|
|
365
376
|
*
|
|
366
377
|
* @param {String} name User name
|
|
367
|
-
* @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout } }`
|
|
378
|
+
* @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout }, displayName, oauth: { consent } }`
|
|
368
379
|
*/
|
|
369
380
|
addUser(name: string, options: UserOptions): void;
|
|
370
381
|
/**
|
|
371
|
-
* Changes the password
|
|
382
|
+
* Changes the password, the XOAUTH2 token, the display name or the OAuth consent policy of a user
|
|
372
383
|
*
|
|
373
384
|
* @param {String} name User name
|
|
374
|
-
* @param {Object} options `{ password, xoauth2 }`, null xoauth2 removes
|
|
385
|
+
* @param {Object} options `{ password, xoauth2, displayName, oauth }`, null xoauth2, displayName or oauth removes it
|
|
375
386
|
*/
|
|
376
387
|
updateUser(name: string, options: UserOptions): void;
|
|
377
388
|
/**
|
|
@@ -421,5 +432,5 @@ declare class Control {
|
|
|
421
432
|
graceful?: boolean | undefined;
|
|
422
433
|
}): Promise<void>;
|
|
423
434
|
}
|
|
424
|
-
export { Control, ImapKitError };
|
|
435
|
+
export { Control, ImapKitError, checkUserOAuth };
|
|
425
436
|
export type { MailboxInfo, MessageInfo, SessionInfo, NewMessage, UserOptions, SessionFilter, ControlRoute, RouteRequest };
|
package/dist/esm/control.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import validateMailboxName from './mailbox-name.js';
|
|
10
10
|
import { DEFAULT_SESSION_TIMEOUT, ImapKitError, expungeMessages, checkFlags, changeFlags, createMailbox, deleteMailbox, renameMailbox, resetUidValidity, subscribeMailbox, unsubscribeMailbox } from './store-operations.js';
|
|
11
|
+
import { checkOutcome } from './oauth/policy.js';
|
|
11
12
|
/**
|
|
12
13
|
* Copies a value of a plugin into the snapshot as JSON data, values that JSON can not hold are left out
|
|
13
14
|
*
|
|
@@ -43,6 +44,20 @@ const NAMESPACE_SKIP = new Set(['folders']);
|
|
|
43
44
|
// properties the server derives when it loads a mailbox, a snapshot leaves them out
|
|
44
45
|
const DERIVED_MAILBOX_KEYS = new Set(['path', 'namespace', 'messages', 'folders', 'subscribed', 'flags']);
|
|
45
46
|
const DERIVED_FLAGS = new Set(['\\HasChildren', '\\HasNoChildren']);
|
|
47
|
+
/**
|
|
48
|
+
* Checks the `oauth` settings of a user
|
|
49
|
+
*
|
|
50
|
+
* @param {*} value `{ consent }`
|
|
51
|
+
* @param {String} what Setting name for the error message
|
|
52
|
+
* @return {Object} a copy
|
|
53
|
+
*/
|
|
54
|
+
function checkUserOAuth(value, what) {
|
|
55
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
56
|
+
throw new ImapKitError(what + ' must be { consent }', 'INVALID');
|
|
57
|
+
}
|
|
58
|
+
const { consent } = value;
|
|
59
|
+
return consent === undefined ? {} : { consent: checkOutcome(consent, what + '.consent') };
|
|
60
|
+
}
|
|
46
61
|
class Control {
|
|
47
62
|
constructor(server) {
|
|
48
63
|
this.server = server;
|
|
@@ -494,7 +509,7 @@ class Control {
|
|
|
494
509
|
* Adds a user
|
|
495
510
|
*
|
|
496
511
|
* @param {String} name User name
|
|
497
|
-
* @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout } }`
|
|
512
|
+
* @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout }, displayName, oauth: { consent } }`
|
|
498
513
|
*/
|
|
499
514
|
addUser(name, options) {
|
|
500
515
|
if (typeof name !== 'string' || !name) {
|
|
@@ -506,10 +521,10 @@ class Control {
|
|
|
506
521
|
this.server.users[name] = this.userData({}, options);
|
|
507
522
|
}
|
|
508
523
|
/**
|
|
509
|
-
* Changes the password
|
|
524
|
+
* Changes the password, the XOAUTH2 token, the display name or the OAuth consent policy of a user
|
|
510
525
|
*
|
|
511
526
|
* @param {String} name User name
|
|
512
|
-
* @param {Object} options `{ password, xoauth2 }`, null xoauth2 removes
|
|
527
|
+
* @param {Object} options `{ password, xoauth2, displayName, oauth }`, null xoauth2, displayName or oauth removes it
|
|
513
528
|
*/
|
|
514
529
|
updateUser(name, options) {
|
|
515
530
|
this.server.users[name] = this.userData(this.requireUser(name), options);
|
|
@@ -534,8 +549,23 @@ class Control {
|
|
|
534
549
|
return this.server.users[name];
|
|
535
550
|
}
|
|
536
551
|
userData(user, options) {
|
|
537
|
-
const { password, xoauth2 } = options || {};
|
|
552
|
+
const { password, xoauth2, displayName, oauth } = options || {};
|
|
538
553
|
const result = Object.assign({}, user);
|
|
554
|
+
if (displayName === null) {
|
|
555
|
+
delete result.displayName;
|
|
556
|
+
}
|
|
557
|
+
else if (displayName !== undefined) {
|
|
558
|
+
if (typeof displayName !== 'string') {
|
|
559
|
+
throw new ImapKitError('displayName must be a string', 'INVALID');
|
|
560
|
+
}
|
|
561
|
+
result.displayName = displayName;
|
|
562
|
+
}
|
|
563
|
+
if (oauth === null) {
|
|
564
|
+
delete result.oauth;
|
|
565
|
+
}
|
|
566
|
+
else if (oauth !== undefined) {
|
|
567
|
+
result.oauth = checkUserOAuth(oauth, 'oauth');
|
|
568
|
+
}
|
|
539
569
|
if (password !== undefined) {
|
|
540
570
|
if (typeof password !== 'string') {
|
|
541
571
|
throw new ImapKitError('Password must be a string', 'INVALID');
|
|
@@ -622,9 +652,9 @@ class Control {
|
|
|
622
652
|
}
|
|
623
653
|
else {
|
|
624
654
|
this.server.closeListeners(false);
|
|
625
|
-
this.server.
|
|
655
|
+
this.server.closeImapListeners(done);
|
|
626
656
|
}
|
|
627
657
|
});
|
|
628
658
|
}
|
|
629
659
|
}
|
|
630
|
-
export { Control, ImapKitError };
|
|
660
|
+
export { Control, ImapKitError, checkUserOAuth };
|
|
@@ -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;
|