@remit/mail-oauth-service 0.0.8 → 0.0.9

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.
@@ -1,398 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
-
4
- import {
5
- createMailOAuthService,
6
- microsoftProviderConfig,
7
- RefreshTokenError,
8
- } from "../index.js";
9
- import type { OAuthProviderConfig } from "../types.js";
10
-
11
- // ---------------------------------------------------------------------------
12
- // Helpers
13
- // ---------------------------------------------------------------------------
14
-
15
- function makeConfig(
16
- overrides?: Partial<OAuthProviderConfig>,
17
- ): OAuthProviderConfig {
18
- return microsoftProviderConfig({
19
- clientId: "test-client-id",
20
- clientSecret: "test-client-secret",
21
- overrides: {
22
- tokenEndpoint: "https://example.com/token",
23
- authorizationEndpoint: "https://example.com/authorize",
24
- ...overrides,
25
- },
26
- });
27
- }
28
-
29
- function nowSecs(): number {
30
- return Math.floor(Date.now() / 1000);
31
- }
32
-
33
- /** Build a fake fetch that returns the given JSON body with the given HTTP status. */
34
- function makeFetch(status: number, body: unknown): typeof globalThis.fetch {
35
- return async (_url, _init) => {
36
- return {
37
- ok: status >= 200 && status < 300,
38
- status,
39
- json: async () => body,
40
- } as Response;
41
- };
42
- }
43
-
44
- /** Counts how many times fetch was called. */
45
- function countingFetch(
46
- status: number,
47
- body: unknown,
48
- ): { fetch: typeof globalThis.fetch; count: () => number } {
49
- let n = 0;
50
- return {
51
- fetch: async (url, init) => {
52
- n++;
53
- return makeFetch(status, body)(url, init);
54
- },
55
- count: () => n,
56
- };
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // Test suite
61
- // ---------------------------------------------------------------------------
62
-
63
- describe("mail-oauth-service", () => {
64
- let originalFetch: typeof globalThis.fetch;
65
-
66
- before(() => {
67
- originalFetch = globalThis.fetch;
68
- });
69
-
70
- after(() => {
71
- globalThis.fetch = originalFetch;
72
- });
73
-
74
- // 1. Happy refresh
75
- test("refresh: 200 response produces correct TokenSet", async () => {
76
- const expiresIn = 3600;
77
- globalThis.fetch = makeFetch(200, {
78
- access_token: "new-access-token",
79
- expires_in: expiresIn,
80
- });
81
-
82
- const svc = createMailOAuthService(makeConfig());
83
- const before = nowSecs();
84
- const result = await svc.refresh("some-refresh-token");
85
- const after = nowSecs();
86
-
87
- assert.equal(result.accessToken, "new-access-token");
88
- assert.ok(
89
- result.expiresAt >= before + expiresIn,
90
- "expiresAt should be ~now + expires_in",
91
- );
92
- assert.ok(
93
- result.expiresAt <= after + expiresIn + 1,
94
- "expiresAt should not be too far in the future",
95
- );
96
- assert.equal(result.refreshToken, undefined);
97
- });
98
-
99
- // 2. Token rotation
100
- test("refresh: rotated refresh_token is surfaced in TokenSet", async () => {
101
- globalThis.fetch = makeFetch(200, {
102
- access_token: "access",
103
- expires_in: 3600,
104
- refresh_token: "new-refresh-token",
105
- });
106
-
107
- const svc = createMailOAuthService(makeConfig());
108
- const result = await svc.refresh("old-refresh-token");
109
-
110
- assert.equal(result.refreshToken, "new-refresh-token");
111
- });
112
-
113
- // 3. Cache hit
114
- test("getAccessToken: two calls within expiry window make only 1 fetch", async () => {
115
- const { fetch, count } = countingFetch(200, {
116
- access_token: "cached-token",
117
- expires_in: 3600,
118
- });
119
- globalThis.fetch = fetch;
120
-
121
- const svc = createMailOAuthService(makeConfig());
122
- const r1 = await svc.getAccessToken("user-1", "rt");
123
- const r2 = await svc.getAccessToken("user-1", "rt");
124
-
125
- assert.equal(count(), 1, "should only fetch once");
126
- assert.equal(r1.accessToken, "cached-token");
127
- assert.equal(r2.accessToken, "cached-token");
128
- });
129
-
130
- // 4. Cache miss at T-5min
131
- test("getAccessToken: token expiring in 4 minutes triggers refresh", async () => {
132
- // First call returns a token that expires in 4 minutes (< 5-min buffer)
133
- let callCount = 0;
134
- globalThis.fetch = async () => {
135
- callCount++;
136
- return {
137
- ok: true,
138
- status: 200,
139
- json: async () => ({
140
- access_token: `token-${callCount}`,
141
- expires_in: callCount === 1 ? 4 * 60 : 3600,
142
- }),
143
- } as Response;
144
- };
145
-
146
- const svc = createMailOAuthService(makeConfig());
147
- // Prime the cache with a soon-expiring token
148
- const r1 = await svc.getAccessToken("user-2", "rt");
149
- assert.equal(r1.accessToken, "token-1");
150
-
151
- // Second call should refresh because the token is within the 5-min buffer
152
- const r2 = await svc.getAccessToken("user-2", "rt");
153
- assert.equal(callCount, 2, "should have refreshed");
154
- assert.equal(r2.accessToken, "token-2");
155
- });
156
-
157
- // 5. Single-flight
158
- test("getAccessToken: 10 concurrent callers produce exactly 1 fetch", async () => {
159
- const { fetch, count } = countingFetch(200, {
160
- access_token: "shared-token",
161
- expires_in: 3600,
162
- });
163
- globalThis.fetch = fetch;
164
-
165
- const svc = createMailOAuthService(makeConfig());
166
- const results = await Promise.all(
167
- Array.from({ length: 10 }, () => svc.getAccessToken("user-3", "rt")),
168
- );
169
-
170
- assert.equal(
171
- count(),
172
- 1,
173
- "should only fetch once despite 10 concurrent callers",
174
- );
175
- for (const r of results) {
176
- assert.equal(r.accessToken, "shared-token");
177
- }
178
- });
179
-
180
- // 6a. Error mapping: invalid_grant → reauth-required
181
- describe("error mapping", () => {
182
- test("invalid_grant → reauth-required", async () => {
183
- globalThis.fetch = makeFetch(400, {
184
- error: "invalid_grant",
185
- error_description: "AADSTS70008: Refresh token expired.",
186
- error_codes: [70008],
187
- });
188
-
189
- const svc = createMailOAuthService(makeConfig());
190
- await assert.rejects(
191
- () => svc.refresh("bad-rt"),
192
- (err: unknown) => {
193
- assert.ok(err instanceof RefreshTokenError);
194
- assert.equal(err.error.kind, "reauth-required");
195
- assert.equal(err.error.code, "invalid_grant");
196
- return true;
197
- },
198
- );
199
- });
200
-
201
- // 6b. error_codes array containing reauth code
202
- test("error_codes [70008] → reauth-required", async () => {
203
- globalThis.fetch = makeFetch(400, {
204
- error: "some_error",
205
- error_codes: [70008],
206
- });
207
-
208
- const svc = createMailOAuthService(makeConfig());
209
- await assert.rejects(
210
- () => svc.refresh("bad-rt"),
211
- (err: unknown) => {
212
- assert.ok(err instanceof RefreshTokenError);
213
- assert.equal(err.error.kind, "reauth-required");
214
- return true;
215
- },
216
- );
217
- });
218
-
219
- // 6c. 503 → transient
220
- test("503 → transient", async () => {
221
- globalThis.fetch = makeFetch(503, {
222
- error: "server_error",
223
- error_description: "Service unavailable",
224
- });
225
-
226
- const svc = createMailOAuthService(makeConfig());
227
- await assert.rejects(
228
- () => svc.refresh("rt"),
229
- (err: unknown) => {
230
- assert.ok(err instanceof RefreshTokenError);
231
- assert.equal(err.error.kind, "transient");
232
- return true;
233
- },
234
- );
235
- });
236
-
237
- // 6d. 429 → transient
238
- test("429 → transient", async () => {
239
- globalThis.fetch = makeFetch(429, {
240
- error: "too_many_requests",
241
- });
242
-
243
- const svc = createMailOAuthService(makeConfig());
244
- await assert.rejects(
245
- () => svc.refresh("rt"),
246
- (err: unknown) => {
247
- assert.ok(err instanceof RefreshTokenError);
248
- assert.equal(err.error.kind, "transient");
249
- return true;
250
- },
251
- );
252
- });
253
-
254
- // 6e. invalid_client → config
255
- test("invalid_client → config", async () => {
256
- globalThis.fetch = makeFetch(401, {
257
- error: "invalid_client",
258
- error_description: "Client secret is invalid.",
259
- });
260
-
261
- const svc = createMailOAuthService(makeConfig());
262
- await assert.rejects(
263
- () => svc.refresh("rt"),
264
- (err: unknown) => {
265
- assert.ok(err instanceof RefreshTokenError);
266
- assert.equal(err.error.kind, "config");
267
- assert.equal(err.error.code, "invalid_client");
268
- return true;
269
- },
270
- );
271
- });
272
-
273
- // 6f. network error → transient
274
- test("network error → transient", async () => {
275
- globalThis.fetch = async () => {
276
- throw new Error("ECONNREFUSED");
277
- };
278
-
279
- const svc = createMailOAuthService(makeConfig());
280
- await assert.rejects(
281
- () => svc.refresh("rt"),
282
- (err: unknown) => {
283
- assert.ok(err instanceof RefreshTokenError);
284
- assert.equal(err.error.kind, "transient");
285
- assert.equal(err.error.code, "network_error");
286
- return true;
287
- },
288
- );
289
- });
290
- });
291
-
292
- // 7. No token leak in error
293
- test("RefreshTokenError does not leak token values", async () => {
294
- const sensitiveRefreshToken = "super-secret-refresh-token-abc123";
295
-
296
- globalThis.fetch = makeFetch(400, {
297
- error: "invalid_grant",
298
- error_codes: [70008],
299
- });
300
-
301
- const svc = createMailOAuthService(makeConfig());
302
-
303
- await assert.rejects(
304
- () => svc.refresh(sensitiveRefreshToken),
305
- (err: unknown) => {
306
- assert.ok(err instanceof RefreshTokenError);
307
-
308
- const serialized = JSON.stringify(err.error);
309
- assert.ok(
310
- !serialized.includes(sensitiveRefreshToken),
311
- "serialized error must not contain the refresh token",
312
- );
313
-
314
- assert.ok(
315
- !err.message.includes(sensitiveRefreshToken),
316
- "error message must not contain the refresh token",
317
- );
318
- return true;
319
- },
320
- );
321
- });
322
-
323
- // 8a. exchangeCode happy path
324
- test("exchangeCode: success returns TokenSet", async () => {
325
- globalThis.fetch = makeFetch(200, {
326
- access_token: "access-from-code",
327
- expires_in: 3600,
328
- refresh_token: "refresh-from-code",
329
- id_token:
330
- "header.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZUBvdXRsb29rLmNvbSJ9.sig",
331
- });
332
-
333
- const svc = createMailOAuthService(makeConfig());
334
- const result = await svc.exchangeCode(
335
- "auth-code",
336
- "https://app.example.com/callback",
337
- );
338
-
339
- assert.equal(result.accessToken, "access-from-code");
340
- assert.equal(result.refreshToken, "refresh-from-code");
341
- assert.equal(
342
- result.idToken,
343
- "header.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZUBvdXRsb29rLmNvbSJ9.sig",
344
- );
345
- });
346
-
347
- // 8b. exchangeCode error mapping
348
- test("exchangeCode: invalid_grant → RefreshTokenError reauth-required", async () => {
349
- globalThis.fetch = makeFetch(400, {
350
- error: "invalid_grant",
351
- error_codes: [70011],
352
- });
353
-
354
- const svc = createMailOAuthService(makeConfig());
355
- await assert.rejects(
356
- () => svc.exchangeCode("bad-code", "https://app.example.com/callback"),
357
- (err: unknown) => {
358
- assert.ok(err instanceof RefreshTokenError);
359
- assert.equal(err.error.kind, "reauth-required");
360
- return true;
361
- },
362
- );
363
- });
364
-
365
- // buildAuthorizationUrl
366
- test("buildAuthorizationUrl includes required params", () => {
367
- const svc = createMailOAuthService(makeConfig());
368
- const url = new URL(
369
- svc.buildAuthorizationUrl({
370
- redirectUri: "https://app.example.com/callback",
371
- state: "random-state",
372
- loginHint: "user@example.com",
373
- }),
374
- );
375
-
376
- assert.equal(url.searchParams.get("client_id"), "test-client-id");
377
- assert.equal(url.searchParams.get("response_type"), "code");
378
- assert.equal(
379
- url.searchParams.get("redirect_uri"),
380
- "https://app.example.com/callback",
381
- );
382
- assert.equal(url.searchParams.get("state"), "random-state");
383
- assert.equal(url.searchParams.get("access_type"), "offline");
384
- assert.equal(url.searchParams.get("prompt"), "consent");
385
- assert.equal(url.searchParams.get("login_hint"), "user@example.com");
386
- });
387
-
388
- test("buildAuthorizationUrl omits login_hint when not provided", () => {
389
- const svc = createMailOAuthService(makeConfig());
390
- const url = new URL(
391
- svc.buildAuthorizationUrl({
392
- redirectUri: "https://app.example.com/callback",
393
- state: "s",
394
- }),
395
- );
396
- assert.equal(url.searchParams.get("login_hint"), null);
397
- });
398
- });
@@ -1,153 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { AccountService } from "@remit/domain-enums";
4
- import {
5
- createMailOAuthService,
6
- MICROSOFT_SERVICE_SCOPES,
7
- microsoftProviderConfig,
8
- microsoftScopes,
9
- microsoftServicesGranted,
10
- normalizeMicrosoftScopes,
11
- } from "../index.js";
12
-
13
- const IMAP = "https://outlook.office.com/IMAP.AccessAsUser.All";
14
- const SMTP = "https://outlook.office.com/SMTP.Send";
15
- const CALENDAR = "https://graph.microsoft.com/Calendars.Read";
16
- const SESSION = ["offline_access", "openid", "email"];
17
-
18
- describe("the Microsoft scopes a service selection asks for", () => {
19
- test("mail asks for IMAP and SMTP and never the calendar", () => {
20
- assert.deepEqual(microsoftScopes([AccountService.Mail]), [
21
- IMAP,
22
- SMTP,
23
- ...SESSION,
24
- ]);
25
- });
26
-
27
- test("calendar asks for the Graph calendar scope and no mailbox access", () => {
28
- assert.deepEqual(microsoftScopes([AccountService.Calendar]), [
29
- CALENDAR,
30
- ...SESSION,
31
- ]);
32
- });
33
-
34
- test("both put mail first whatever order they arrive in", () => {
35
- assert.deepEqual(
36
- microsoftScopes([
37
- AccountService.Calendar,
38
- AccountService.Mail,
39
- AccountService.Calendar,
40
- ]),
41
- [IMAP, SMTP, CALENDAR, ...SESSION],
42
- );
43
- });
44
-
45
- test("a provider config with no selection asks for mail, as before", () => {
46
- const config = microsoftProviderConfig({
47
- clientId: "client-id",
48
- clientSecret: "client-secret",
49
- });
50
-
51
- assert.deepEqual(config.scopes, [IMAP, SMTP, ...SESSION]);
52
- });
53
-
54
- test("the authorization URL carries the scopes of the selection", () => {
55
- const service = createMailOAuthService(
56
- microsoftProviderConfig({
57
- clientId: "client-id",
58
- clientSecret: "client-secret",
59
- services: [AccountService.Calendar],
60
- }),
61
- );
62
-
63
- const url = new URL(
64
- service.buildAuthorizationUrl({
65
- redirectUri: "https://app.example.com/callback",
66
- state: "state",
67
- }),
68
- );
69
-
70
- assert.equal(
71
- url.searchParams.get("scope"),
72
- [CALENDAR, ...SESSION].join(" "),
73
- );
74
- });
75
- });
76
-
77
- describe("the scopes Microsoft reports as granted", () => {
78
- test("bare Graph names become full scope URIs and identity scopes drop out", () => {
79
- assert.deepEqual(
80
- normalizeMicrosoftScopes(
81
- "Calendars.Read openid profile email offline_access",
82
- ),
83
- [CALENDAR],
84
- );
85
- });
86
-
87
- test("Outlook scopes keep their resource", () => {
88
- assert.deepEqual(normalizeMicrosoftScopes(`${IMAP} ${SMTP}`), [IMAP, SMTP]);
89
- });
90
-
91
- test("an absent scope field grants nothing", () => {
92
- assert.deepEqual(normalizeMicrosoftScopes(undefined), []);
93
- });
94
-
95
- test("a service counts as granted only when every one of its scopes is", () => {
96
- assert.deepEqual(microsoftServicesGranted([IMAP]), []);
97
- assert.deepEqual(microsoftServicesGranted([IMAP, SMTP]), [
98
- AccountService.Mail,
99
- ]);
100
- assert.deepEqual(
101
- microsoftServicesGranted([
102
- CALENDAR.toLowerCase(),
103
- ...MICROSOFT_SERVICE_SCOPES[AccountService.Mail],
104
- ]),
105
- [AccountService.Mail, AccountService.Calendar],
106
- );
107
- });
108
- });
109
-
110
- describe("the code exchange", () => {
111
- let originalFetch: typeof globalThis.fetch;
112
- let sentScope: string | null;
113
-
114
- before(() => {
115
- originalFetch = globalThis.fetch;
116
- globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
117
- sentScope = new URLSearchParams(String(init?.body)).get("scope");
118
- return {
119
- ok: true,
120
- status: 200,
121
- json: async () => ({
122
- access_token: "access",
123
- expires_in: 3600,
124
- refresh_token: "refresh",
125
- scope: "Calendars.Read openid email",
126
- }),
127
- } as Response;
128
- }) as typeof globalThis.fetch;
129
- });
130
-
131
- after(() => {
132
- globalThis.fetch = originalFetch;
133
- });
134
-
135
- test("redeems the code for the selection's scopes and reports what was granted", async () => {
136
- const service = createMailOAuthService(
137
- microsoftProviderConfig({
138
- clientId: "client-id",
139
- clientSecret: "client-secret",
140
- services: [AccountService.Calendar],
141
- overrides: { tokenEndpoint: "https://example.com/token" },
142
- }),
143
- );
144
-
145
- const tokenSet = await service.exchangeCode(
146
- "code",
147
- "https://app.example.com/callback",
148
- );
149
-
150
- assert.equal(sentScope, [CALENDAR, ...SESSION].join(" "));
151
- assert.deepEqual(tokenSet.grantedScopes, [CALENDAR]);
152
- });
153
- });
package/src/index.ts DELETED
@@ -1,40 +0,0 @@
1
- export * from "./mail-oauth-service.js";
2
- export * from "./microsoft-errors.js";
3
- export * from "./microsoft-scopes.js";
4
- export * from "./types.js";
5
-
6
- import { AccountService } from "@remit/domain-enums";
7
- import {
8
- type AccountServiceName,
9
- microsoftScopes,
10
- } from "./microsoft-scopes.js";
11
- import type { OAuthProviderConfig } from "./types.js";
12
-
13
- const MICROSOFT_TOKEN_ENDPOINT =
14
- "https://login.microsoftonline.com/common/oauth2/v2.0/token";
15
- const MICROSOFT_AUTHORIZATION_ENDPOINT =
16
- "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
17
-
18
- export function microsoftProviderConfig(opts: {
19
- clientId: string;
20
- clientSecret: string;
21
- services?: readonly AccountServiceName[];
22
- overrides?: Partial<
23
- Pick<
24
- OAuthProviderConfig,
25
- "tokenEndpoint" | "authorizationEndpoint" | "scopes"
26
- >
27
- >;
28
- }): OAuthProviderConfig {
29
- return {
30
- provider: "microsoft",
31
- clientId: opts.clientId,
32
- clientSecret: opts.clientSecret,
33
- tokenEndpoint: opts.overrides?.tokenEndpoint ?? MICROSOFT_TOKEN_ENDPOINT,
34
- authorizationEndpoint:
35
- opts.overrides?.authorizationEndpoint ?? MICROSOFT_AUTHORIZATION_ENDPOINT,
36
- scopes:
37
- opts.overrides?.scopes ??
38
- microsoftScopes(opts.services ?? [AccountService.Mail]),
39
- };
40
- }