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.
Files changed (48) hide show
  1. package/README.md +41 -23
  2. package/bin/help.txt +29 -3
  3. package/bin/imapkit.js +43 -0
  4. package/dist/browser/imapkit.js +99 -35
  5. package/dist/cjs/control.d.ts +16 -5
  6. package/dist/cjs/control.js +36 -5
  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 +63 -35
  21. package/dist/cjs/server.d.ts +49 -1
  22. package/dist/cjs/server.js +126 -12
  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 +16 -0
  26. package/dist/esm/control.d.ts +16 -5
  27. package/dist/esm/control.js +36 -6
  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 +62 -36
  42. package/dist/esm/server.d.ts +49 -1
  43. package/dist/esm/server.js +127 -13
  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 +16 -0
  47. package/dist/plugin-help.json +2 -2
  48. package/package.json +7 -7
@@ -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;
@@ -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 or the XOAUTH2 token of a user
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 the token
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 };
@@ -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 or the XOAUTH2 token of a user
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 the token
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.server.close(done);
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;