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,455 @@
1
+ /**
2
+ * The OAuth 2.0 / OpenID Connect provider of the `oauth` option (README "OAuth 2.0 and OpenID Connect"): the
3
+ * authorization code flow with PKCE for mail clients, with tokens that IMAP (XOAUTH2, OAUTHBEARER) and SMTP (AUTH
4
+ * XOAUTH2) accept. No passwords: a consent policy decides every sign-in, from the first rung that has one: a queued
5
+ * decision, the user (picked by `login_hint`), the client, the server default. `prompt` shows the consent page,
6
+ * where the tester picks an account and clicks Allow or Deny. Strict like the IMAP side: a request that breaks a
7
+ * MUST of the RFCs is refused and the error description names the rule.
8
+ *
9
+ * This file holds the protocol logic and the state, src/oauth/http.ts the HTTP server and src/oauth/pages.ts the
10
+ * pages. Times come from `server.now()`, so tests with a fixed clock can let tokens expire.
11
+ */
12
+ import type { ConsentPolicy, Outcome } from './policy.js';
13
+ import type { SigningKey } from './jwt.js';
14
+ import type { IMAPServer } from '../types.js';
15
+ /** A client of the `oauth.clients` option */
16
+ export interface OAuthClientOptions {
17
+ /** the name the consent page shows, the client ID if not set */
18
+ name?: string | undefined;
19
+ /** a confidential client authenticates with this secret, a client without one is public and must use PKCE */
20
+ secret?: string | undefined;
21
+ /** registered redirect URIs. A loopback http URI matches any port (RFC 8252 section 7.3), a trailing `*` any rest */
22
+ redirectUris?: string[] | undefined;
23
+ /** the consent policy of the client, the server default if not set */
24
+ consent?: ConsentPolicy | undefined;
25
+ }
26
+ /** The `oauth` option */
27
+ export interface OAuthOptions {
28
+ port?: number | undefined;
29
+ /** address to listen on, 127.0.0.1 by default */
30
+ host?: string | undefined;
31
+ /** HTTPS with the `credentials` or the bundled localhost certificate */
32
+ secure?: boolean | undefined;
33
+ /** the public base URL, when clients reach the provider through another address (a tunnel, a proxy) */
34
+ issuer?: string | undefined;
35
+ /** the default consent policy, `prompt` if not set */
36
+ consent?: ConsentPolicy | undefined;
37
+ /** registered clients by client ID, `imapkit`, `imapkit-approve` and `imapkit-deny` if not set */
38
+ clients?: Record<string, OAuthClientOptions> | undefined;
39
+ /** more scopes, name to the description the consent page shows */
40
+ scopes?: Record<string, string> | undefined;
41
+ /** the scope that IMAP and SMTP logins need, `mail` by default */
42
+ mailScope?: string | undefined;
43
+ /** `refuse` (default) answers a request for an unknown scope with invalid_scope, `drop` grants the others */
44
+ unknownScopes?: 'refuse' | 'drop' | undefined;
45
+ /** `offline_access` (default) issues refresh tokens only for that scope, `always` for every code exchange */
46
+ refreshTokens?: 'offline_access' | 'always' | undefined;
47
+ /** a new refresh token on every refresh, reuse of an old one revokes the grant */
48
+ rotateRefreshTokens?: boolean | undefined;
49
+ /** access token lifetime in seconds, 3600 by default */
50
+ accessTokenTtl?: number | undefined;
51
+ /** authorization code lifetime in seconds, 60 by default, at most 600 (RFC 6749 section 4.1.2) */
52
+ codeTtl?: number | undefined;
53
+ /** accepts the `plain` PKCE method, which RFC 7636 section 4.2 only allows for clients that can not do S256 */
54
+ allowPlainPkce?: boolean | undefined;
55
+ /** RSA private key in PEM for the ID tokens, a new key on every start if not set */
56
+ signingKey?: string | Buffer | undefined;
57
+ }
58
+ /** A registered client */
59
+ export interface OAuthClient {
60
+ id: string;
61
+ name: string;
62
+ secret: string | null;
63
+ redirectUris: string[];
64
+ consent: ConsentPolicy | null;
65
+ }
66
+ /** One line of the request checklist on the consent page */
67
+ export interface Check {
68
+ level: 'ok' | 'note' | 'warn';
69
+ text: string;
70
+ }
71
+ /** A checked authorization request */
72
+ export interface AuthRequest {
73
+ client: OAuthClient;
74
+ redirectUri: string;
75
+ /** false when the request left redirect_uri out and the only registered URI was used */
76
+ redirectUriSent: boolean;
77
+ state: string | undefined;
78
+ scope: string[];
79
+ codeChallenge: string | undefined;
80
+ codeChallengeMethod: string | undefined;
81
+ nonce: string | undefined;
82
+ loginHint: string | undefined;
83
+ /** the user that login_hint names, null when it names nobody */
84
+ hintUser: string | null | undefined;
85
+ prompt: string[];
86
+ checks: Check[];
87
+ /** other parameters the request sent, they are ignored */
88
+ ignored: string[];
89
+ }
90
+ /** A request waiting on the consent page */
91
+ export interface PendingRequest extends AuthRequest {
92
+ id: string;
93
+ expiresAt: number;
94
+ source: string;
95
+ }
96
+ /** A page to show instead of a redirect */
97
+ export interface PageData {
98
+ status: number;
99
+ title: string;
100
+ message: string;
101
+ details?: string[] | undefined;
102
+ tone: 'error' | 'info';
103
+ }
104
+ /** What the authorization endpoint answers */
105
+ export type AuthorizeResult = {
106
+ type: 'redirect';
107
+ location: string;
108
+ } | {
109
+ type: 'page';
110
+ page: PageData;
111
+ } | {
112
+ type: 'consent';
113
+ request: PendingRequest;
114
+ };
115
+ /** A JSON answer of the token, revocation or userinfo endpoint */
116
+ export interface JsonResult {
117
+ status: number;
118
+ body: unknown;
119
+ headers?: Record<string, string> | undefined;
120
+ /** answer with an HTML error page instead of JSON, the html token fault */
121
+ htmlError?: boolean | undefined;
122
+ }
123
+ /** A queued decision, see queueOAuthDecision() */
124
+ export interface QueuedDecision {
125
+ id: number;
126
+ decision: Outcome;
127
+ client: string | undefined;
128
+ user: string | undefined;
129
+ remaining: number;
130
+ }
131
+ /** A queued fault of the token endpoint, see queueOAuthTokenFault() */
132
+ export interface TokenFault {
133
+ id: number;
134
+ grant: 'authorization_code' | 'refresh_token' | undefined;
135
+ error: string | undefined;
136
+ description: string | undefined;
137
+ status: number | undefined;
138
+ html: boolean;
139
+ delay: number;
140
+ /** claims to change in the ID token, null removes one */
141
+ claims: Record<string, unknown> | undefined;
142
+ remaining: number;
143
+ }
144
+ interface CodeRecord {
145
+ clientId: string;
146
+ user: string;
147
+ redirectUri: string;
148
+ redirectUriSent: boolean;
149
+ scope: string[];
150
+ challenge: string | undefined;
151
+ method: string | undefined;
152
+ nonce: string | undefined;
153
+ expiresAt: number;
154
+ authTime: number;
155
+ grantId: string;
156
+ used: boolean;
157
+ }
158
+ interface TokenRecord {
159
+ token: string;
160
+ type: 'access' | 'refresh';
161
+ clientId: string;
162
+ user: string;
163
+ scope: string[];
164
+ grantId: string;
165
+ /** null for refresh tokens, which do not expire */
166
+ expiresAt: number | null;
167
+ revoked: boolean;
168
+ /** a refresh token that rotation replaced */
169
+ rotated: boolean;
170
+ /** when the token was revoked or replaced, prune() forgets it a day later */
171
+ endedAt: number | null;
172
+ }
173
+ /** What `server.checkAccessToken()` gets from the provider */
174
+ export type TokenCheck = {
175
+ user: string;
176
+ } | {
177
+ error: 'invalid_token' | 'insufficient_scope';
178
+ reason: string;
179
+ };
180
+ /**
181
+ * Checks a registered redirect URI against the one a request sent
182
+ *
183
+ * @param {String} registered Registered URI, a trailing `*` matches any rest
184
+ * @param {String} given URI of the request
185
+ * @return {Boolean} true if they match
186
+ */
187
+ declare function redirectMatches(registered: string, given: string): boolean;
188
+ declare class OAuthProvider {
189
+ server: IMAPServer;
190
+ options: OAuthOptions;
191
+ /** the base URL of the endpoints, set when the HTTP server listens (see listening()) */
192
+ issuer: string;
193
+ clients: Map<string, OAuthClient>;
194
+ consent: ConsentPolicy;
195
+ decisions: QueuedDecision[];
196
+ tokenFaults: TokenFault[];
197
+ pending: Map<string, PendingRequest>;
198
+ codes: Map<string, CodeRecord>;
199
+ tokens: Map<string, TokenRecord>;
200
+ scopes: Record<string, string>;
201
+ mailScope: string;
202
+ accessTokenTtl: number;
203
+ codeTtl: number;
204
+ queueCounter: number;
205
+ key: SigningKey | null;
206
+ constructor(server: IMAPServer, options: OAuthOptions);
207
+ /**
208
+ * Checks an issuer URL of the options: http or https, no query or fragment (RFC 8414 section 2)
209
+ *
210
+ * @param {String} issuer URL
211
+ * @return {String} the URL without a trailing slash
212
+ */
213
+ checkIssuer(issuer: unknown): string;
214
+ /** Restores the clients and the default policy of the options and forgets every token, request and queue */
215
+ reset(): void;
216
+ /** Milliseconds since the epoch, from the `now` option */
217
+ nowMs(): number;
218
+ /**
219
+ * Sets the issuer from the address the HTTP server listens on, unless the `issuer` option gave one
220
+ *
221
+ * @param {String} host Address
222
+ * @param {Number} port Port
223
+ */
224
+ listening(host: string, port: number): void;
225
+ /** The URL of the discovery document */
226
+ discoveryUrl(): string;
227
+ /** The users as the consent and index pages show them, the default user first */
228
+ pageUsers(): {
229
+ name: string;
230
+ displayName: string | undefined;
231
+ consent: ConsentPolicy | undefined;
232
+ }[];
233
+ /**
234
+ * Marks a token as revoked or replaced by rotation
235
+ *
236
+ * @param {Object} record Token
237
+ * @param {String} how "revoked" or "rotated"
238
+ */
239
+ endToken(record: TokenRecord, how: 'revoked' | 'rotated'): void;
240
+ /**
241
+ * Emits an `oauth` event, `{ type, ... }`, and logs it with the debug option
242
+ *
243
+ * @param {String} type Event type
244
+ * @param {Object} data Event data
245
+ */
246
+ emit(type: string, data: Record<string, unknown>): void;
247
+ /**
248
+ * Answers an authorization request (RFC 6749 section 4.1.1, OpenID Connect Core 1.0 section 3.1.2.1)
249
+ *
250
+ * @param {URLSearchParams} input Query (GET) or form (POST) parameters
251
+ * @return {Object} a redirect, a page, or a request for the consent page
252
+ */
253
+ authorize(input: URLSearchParams): AuthorizeResult;
254
+ /**
255
+ * Checks the parameters of an authorization request after the client and the redirect URI
256
+ *
257
+ * @return {Object} the request, or the error redirect
258
+ */
259
+ checkRequest(client: OAuthClient, redirectUri: string, p: Record<string, string>, duplicates: string[], fail: (error: string, description: string) => AuthorizeResult): AuthRequest | AuthorizeResult;
260
+ /**
261
+ * Finds the redirect URI of a request
262
+ *
263
+ * @return {String|Object} the URI, or the error page
264
+ */
265
+ resolveRedirect(client: OAuthClient, given: string | undefined): string | AuthorizeResult;
266
+ /**
267
+ * Checks the scope parameter
268
+ *
269
+ * @return {Array|String} the granted scopes, or the error description
270
+ */
271
+ checkScope(value: string | undefined, checks: Check[]): string[] | string;
272
+ /**
273
+ * Checks the PKCE parameters (RFC 7636 section 4.3, RFC 9700 section 2.1.1)
274
+ *
275
+ * @return {String|null} the error description, null if they are fine
276
+ */
277
+ checkPkce(client: OAuthClient, p: Record<string, string>, checks: Check[]): string | null;
278
+ /**
279
+ * Finds the decision of a request: a queued decision, the user of login_hint, the client, the server default
280
+ *
281
+ * @param {Object} request Checked request
282
+ * @return {Object} `{ outcome, source }`
283
+ */
284
+ decide(request: AuthRequest): {
285
+ outcome: Outcome;
286
+ source: string;
287
+ };
288
+ /**
289
+ * Carries out a decision
290
+ *
291
+ * @param {Object} request Checked request
292
+ * @param {String|Object} outcome Decision
293
+ * @param {String} source Where the decision came from
294
+ * @param {String} [user] The user picked on the consent page
295
+ * @return {Object} redirect, page or consent request
296
+ */
297
+ finish(request: AuthRequest, outcome: Outcome, source: string, user?: string): AuthorizeResult;
298
+ /**
299
+ * The user an approved request signs in: the user of login_hint, or the first user
300
+ *
301
+ * @return {String|null} user name, null if there is none
302
+ */
303
+ pickUser(request: AuthRequest): string | null;
304
+ /** Sorted user names, the default user first */
305
+ userNames(): string[];
306
+ /**
307
+ * Answers the form of the consent page
308
+ *
309
+ * @param {URLSearchParams} form Form fields: request, action (allow, deny, outcome), user, remember, outcome, description
310
+ * @return {Object} redirect or page
311
+ */
312
+ answerConsent(form: URLSearchParams): AuthorizeResult;
313
+ /**
314
+ * Drops expired consent requests and codes, and tokens a day after they expired or ended. Until then a login with
315
+ * one is refused as expired or revoked, after that as unknown
316
+ */
317
+ prune(): void;
318
+ issueCode(request: AuthRequest, user: string): string;
319
+ /**
320
+ * Builds a redirect to the client, with `iss` (RFC 9207 section 2)
321
+ *
322
+ * @param {String} redirectUri Redirect URI, its query is kept (RFC 6749 section 3.1.2)
323
+ * @param {Object} params Response parameters
324
+ * @param {Boolean} [wrongIss] true sends another issuer, the wrong-iss fault
325
+ * @return {String} URL
326
+ */
327
+ redirectUrl(redirectUri: string, params: Record<string, string>, wrongIss?: boolean): string;
328
+ redirectError(target: {
329
+ redirectUri: string;
330
+ state: string | undefined;
331
+ }, error: string, description: string): AuthorizeResult;
332
+ errorPage(title: string, message: string, details?: string[]): AuthorizeResult;
333
+ clientList(): string[];
334
+ /**
335
+ * Answers a token request (RFC 6749 sections 4.1.3 and 6)
336
+ *
337
+ * @param {URLSearchParams} form Form parameters
338
+ * @param {String} [authorization] Authorization header
339
+ * @return {Promise<Object>} JSON answer
340
+ */
341
+ token(form: URLSearchParams, authorization: string | undefined): Promise<JsonResult>;
342
+ /**
343
+ * Authenticates the client of a token or revocation request (RFC 6749 section 2.3)
344
+ *
345
+ * @return {Object} `{ client }` or the error answer
346
+ */
347
+ authenticateClient(p: Record<string, string>, authorization: string | undefined): {
348
+ client: OAuthClient;
349
+ } | JsonResult;
350
+ exchangeCode(client: OAuthClient, p: Record<string, string>, fault: TokenFault | null): JsonResult;
351
+ refresh(client: OAuthClient, p: Record<string, string>): JsonResult;
352
+ /**
353
+ * Issues an access token, and a refresh token and an ID token when the grant calls for them
354
+ *
355
+ * @return {Object} token response (RFC 6749 section 5.1)
356
+ */
357
+ issueTokens(client: OAuthClient, user: string, scope: string[], grantId: string, extra: {
358
+ nonce?: string | undefined;
359
+ authTime?: number | undefined;
360
+ fault?: TokenFault | null | undefined;
361
+ refresh?: TokenRecord | undefined;
362
+ }): Record<string, unknown>;
363
+ newRefreshToken(client: OAuthClient, user: string, scope: string[], grantId: string): string;
364
+ /**
365
+ * Signs an ID token (OpenID Connect Core 1.0 section 2). The user name is the email address
366
+ *
367
+ * @return {String} JWT
368
+ */
369
+ idToken(client: OAuthClient, user: string, scope: string[], nonce: string | undefined, authTime: number | undefined, changes: Record<string, unknown> | undefined): string;
370
+ /** The claims of the email and profile scopes */
371
+ userClaims(user: string, scope: string[]): Record<string, unknown>;
372
+ /** The public key of the ID tokens (RFC 7517) */
373
+ jwks(): {
374
+ keys: Record<string, string>[];
375
+ };
376
+ takeTokenFault(grant: string | undefined): TokenFault | null;
377
+ /**
378
+ * Revokes every token of a grant
379
+ *
380
+ * @return {Number} how many tokens were revoked
381
+ */
382
+ revokeGrant(grantId: string): number;
383
+ /**
384
+ * Answers a revocation request (RFC 7009 section 2)
385
+ *
386
+ * @return {Object} JSON answer
387
+ */
388
+ revoke(form: URLSearchParams, authorization: string | undefined): JsonResult;
389
+ /**
390
+ * Answers a UserInfo request (OpenID Connect Core 1.0 section 5.3)
391
+ *
392
+ * @param {String} [authorization] Authorization header with the access token
393
+ * @return {Object} JSON answer
394
+ */
395
+ userinfo(authorization: string | undefined): JsonResult;
396
+ /**
397
+ * Why an access token can not be used
398
+ *
399
+ * @return {String|null} "expired", "revoked" ..., null for a usable token
400
+ */
401
+ tokenProblem(record: TokenRecord | undefined): string | null;
402
+ /**
403
+ * Checks an access token for an IMAP or SMTP login
404
+ *
405
+ * @param {String} token Access token
406
+ * @param {String} [username] The user the client logs in as, any if not set
407
+ * @param {String} via "imap" or "smtp", for the event
408
+ * @return {Object|null} `{ user }` or `{ error, reason }`, null for a token the provider did not issue
409
+ */
410
+ verifyAccessToken(token: string, username: string | undefined, via: string): TokenCheck | null;
411
+ /** The discovery document (OpenID Connect Discovery 1.0 section 3, RFC 8414 section 2) */
412
+ metadata(): Record<string, unknown>;
413
+ describeClient(client: OAuthClient): Record<string, unknown>;
414
+ describeToken(record: TokenRecord): Record<string, unknown>;
415
+ /**
416
+ * Selects tokens for the control API
417
+ *
418
+ * @param {Object} [filter] `{ user, client, token, type }`
419
+ * @return {Array} token records
420
+ */
421
+ findTokens(filter?: {
422
+ user?: unknown;
423
+ client?: unknown;
424
+ token?: unknown;
425
+ type?: unknown;
426
+ }): TokenRecord[];
427
+ queueDecision(options?: {
428
+ decision?: unknown;
429
+ client?: unknown;
430
+ user?: unknown;
431
+ count?: unknown;
432
+ }): {
433
+ id: number;
434
+ };
435
+ queueTokenFault(options?: Record<string, unknown>): {
436
+ id: number;
437
+ };
438
+ listClients(): Record<string, unknown>[];
439
+ setClient(id: string, options: OAuthClientOptions): Record<string, unknown>;
440
+ deleteClient(id: string): void;
441
+ setConsent(consent: unknown): {
442
+ consent: ConsentPolicy;
443
+ };
444
+ listTokens(filter?: Record<string, unknown>): Record<string, unknown>[];
445
+ expireTokens(filter?: Record<string, unknown>): {
446
+ expired: number;
447
+ };
448
+ revokeTokens(filter?: Record<string, unknown>): {
449
+ revoked: number;
450
+ };
451
+ info(): Record<string, unknown>;
452
+ /** Adds the control operations and their REST routes, see README "Control API" */
453
+ registerControl(): void;
454
+ }
455
+ export { OAuthProvider, redirectMatches };