@remit/mail-oauth-service 0.0.1
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/package.json +27 -0
- package/src/__tests__/mail-oauth-service.test.ts +398 -0
- package/src/index.ts +38 -0
- package/src/mail-oauth-service.ts +207 -0
- package/src/microsoft-errors.ts +86 -0
- package/src/types.ts +33 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/mail-oauth-service",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test:typecheck": "tsgo --noEmit",
|
|
15
|
+
"test:run": "node --import tsx --test 'src/**/*.test.ts'",
|
|
16
|
+
"test": "npm run test:typecheck && npm run test:run"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
25
|
+
"directory": "packages/mail-oauth-service"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export * from "./mail-oauth-service.js";
|
|
2
|
+
export * from "./microsoft-errors.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
|
|
5
|
+
import type { OAuthProviderConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const MICROSOFT_TOKEN_ENDPOINT =
|
|
8
|
+
"https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
9
|
+
const MICROSOFT_AUTHORIZATION_ENDPOINT =
|
|
10
|
+
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
11
|
+
const MICROSOFT_DEFAULT_SCOPES = [
|
|
12
|
+
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
|
13
|
+
"https://outlook.office.com/SMTP.Send",
|
|
14
|
+
"offline_access",
|
|
15
|
+
"openid",
|
|
16
|
+
"email",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function microsoftProviderConfig(opts: {
|
|
20
|
+
clientId: string;
|
|
21
|
+
clientSecret: string;
|
|
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: opts.overrides?.scopes ?? MICROSOFT_DEFAULT_SCOPES,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyMicrosoftError,
|
|
3
|
+
type MicrosoftTokenErrorResponse,
|
|
4
|
+
} from "./microsoft-errors.js";
|
|
5
|
+
import {
|
|
6
|
+
type OAuthProviderConfig,
|
|
7
|
+
RefreshTokenError,
|
|
8
|
+
type TokenSet,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface MailOAuthService {
|
|
12
|
+
/** Exchange an authorization code for a token set. */
|
|
13
|
+
exchangeCode(
|
|
14
|
+
code: string,
|
|
15
|
+
redirectUri: string,
|
|
16
|
+
codeVerifier?: string,
|
|
17
|
+
): Promise<TokenSet>;
|
|
18
|
+
|
|
19
|
+
/** Refresh an access token using a refresh token. Throws RefreshTokenError on failure. */
|
|
20
|
+
refresh(refreshToken: string): Promise<TokenSet>;
|
|
21
|
+
|
|
22
|
+
/** Build the OAuth2 authorization URL to redirect the user to. */
|
|
23
|
+
buildAuthorizationUrl(opts: {
|
|
24
|
+
redirectUri: string;
|
|
25
|
+
state: string;
|
|
26
|
+
loginHint?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return a valid access token for the given cache key, refreshing if needed.
|
|
31
|
+
* Concurrent callers for the same key share a single in-flight refresh (single-flight).
|
|
32
|
+
*
|
|
33
|
+
* Rotation contract: if the provider rotates the refresh token, the new value is
|
|
34
|
+
* surfaced in the returned `TokenSet.refreshToken`. The caller is responsible for
|
|
35
|
+
* persisting it — this service never persists tokens.
|
|
36
|
+
*/
|
|
37
|
+
getAccessToken(cacheKey: string, refreshToken: string): Promise<TokenSet>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tokens are refreshed 5 minutes before expiry. */
|
|
41
|
+
const EXPIRY_BUFFER_SECS = 5 * 60;
|
|
42
|
+
|
|
43
|
+
interface CacheEntry {
|
|
44
|
+
tokenSet?: TokenSet;
|
|
45
|
+
promise?: Promise<TokenSet>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RawTokenResponse {
|
|
49
|
+
access_token: string;
|
|
50
|
+
expires_in: number;
|
|
51
|
+
refresh_token?: string;
|
|
52
|
+
id_token?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
error_codes?: number[];
|
|
55
|
+
error_description?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseTokenResponse(
|
|
59
|
+
httpStatus: number,
|
|
60
|
+
raw: RawTokenResponse,
|
|
61
|
+
): TokenSet {
|
|
62
|
+
if (raw.error) {
|
|
63
|
+
const classified = classifyMicrosoftError(
|
|
64
|
+
httpStatus,
|
|
65
|
+
raw as MicrosoftTokenErrorResponse,
|
|
66
|
+
);
|
|
67
|
+
throw new RefreshTokenError(classified);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
accessToken: raw.access_token,
|
|
72
|
+
expiresAt: Math.floor(Date.now() / 1000) + raw.expires_in,
|
|
73
|
+
...(raw.refresh_token ? { refreshToken: raw.refresh_token } : {}),
|
|
74
|
+
...(raw.id_token ? { idToken: raw.id_token } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function postForm(
|
|
79
|
+
url: string,
|
|
80
|
+
params: Record<string, string>,
|
|
81
|
+
): Promise<TokenSet> {
|
|
82
|
+
const body = new URLSearchParams(params).toString();
|
|
83
|
+
|
|
84
|
+
let response: Response;
|
|
85
|
+
try {
|
|
86
|
+
response = await fetch(url, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
} catch (_cause) {
|
|
92
|
+
// network error → transient
|
|
93
|
+
throw new RefreshTokenError({ kind: "transient", code: "network_error" });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let raw: RawTokenResponse;
|
|
97
|
+
try {
|
|
98
|
+
raw = (await response.json()) as RawTokenResponse;
|
|
99
|
+
} catch {
|
|
100
|
+
throw new RefreshTokenError({
|
|
101
|
+
kind: "transient",
|
|
102
|
+
code: "invalid_response",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!response.ok && !raw.error) {
|
|
107
|
+
// Non-OK HTTP with no error body → treat as transient
|
|
108
|
+
throw new RefreshTokenError({
|
|
109
|
+
kind: "transient",
|
|
110
|
+
code: `http_${response.status}`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return parseTokenResponse(response.status, raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isExpiringSoon(tokenSet: TokenSet): boolean {
|
|
118
|
+
const nowSecs = Math.floor(Date.now() / 1000);
|
|
119
|
+
return tokenSet.expiresAt - nowSecs < EXPIRY_BUFFER_SECS;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createMailOAuthService(
|
|
123
|
+
config: OAuthProviderConfig,
|
|
124
|
+
): MailOAuthService {
|
|
125
|
+
const cache = new Map<string, CacheEntry>();
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
buildAuthorizationUrl({ redirectUri, state, loginHint }) {
|
|
129
|
+
const url = new URL(config.authorizationEndpoint);
|
|
130
|
+
url.searchParams.set("client_id", config.clientId);
|
|
131
|
+
url.searchParams.set("response_type", "code");
|
|
132
|
+
url.searchParams.set("scope", config.scopes.join(" "));
|
|
133
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
134
|
+
url.searchParams.set("state", state);
|
|
135
|
+
url.searchParams.set("access_type", "offline"); // Google compat
|
|
136
|
+
url.searchParams.set("prompt", "consent"); // reliably get refresh token
|
|
137
|
+
if (loginHint) {
|
|
138
|
+
url.searchParams.set("login_hint", loginHint);
|
|
139
|
+
}
|
|
140
|
+
return url.toString();
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async exchangeCode(code, redirectUri, codeVerifier) {
|
|
144
|
+
const params: Record<string, string> = {
|
|
145
|
+
grant_type: "authorization_code",
|
|
146
|
+
code,
|
|
147
|
+
redirect_uri: redirectUri,
|
|
148
|
+
client_id: config.clientId,
|
|
149
|
+
client_secret: config.clientSecret,
|
|
150
|
+
};
|
|
151
|
+
if (codeVerifier) {
|
|
152
|
+
params.code_verifier = codeVerifier;
|
|
153
|
+
}
|
|
154
|
+
return postForm(config.tokenEndpoint, params);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async refresh(refreshToken) {
|
|
158
|
+
return postForm(config.tokenEndpoint, {
|
|
159
|
+
grant_type: "refresh_token",
|
|
160
|
+
refresh_token: refreshToken,
|
|
161
|
+
client_id: config.clientId,
|
|
162
|
+
client_secret: config.clientSecret,
|
|
163
|
+
scope: config.scopes.join(" "),
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async getAccessToken(cacheKey, refreshToken) {
|
|
168
|
+
const entry = cache.get(cacheKey);
|
|
169
|
+
|
|
170
|
+
// Return cached token if still valid
|
|
171
|
+
if (entry?.tokenSet && !isExpiringSoon(entry.tokenSet)) {
|
|
172
|
+
return entry.tokenSet;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Single-flight: if a refresh is already in-flight for this key, await it
|
|
176
|
+
if (entry?.promise) {
|
|
177
|
+
return entry.promise;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Start a new refresh
|
|
181
|
+
const promise = postForm(config.tokenEndpoint, {
|
|
182
|
+
grant_type: "refresh_token",
|
|
183
|
+
refresh_token: refreshToken,
|
|
184
|
+
client_id: config.clientId,
|
|
185
|
+
client_secret: config.clientSecret,
|
|
186
|
+
scope: config.scopes.join(" "),
|
|
187
|
+
}).then(
|
|
188
|
+
(tokenSet) => {
|
|
189
|
+
cache.set(cacheKey, { tokenSet });
|
|
190
|
+
return tokenSet;
|
|
191
|
+
},
|
|
192
|
+
(err) => {
|
|
193
|
+
// Clear the promise so next caller tries again
|
|
194
|
+
const current = cache.get(cacheKey);
|
|
195
|
+
if (current?.promise === promise) {
|
|
196
|
+
cache.delete(cacheKey);
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
cache.set(cacheKey, { tokenSet: entry?.tokenSet, promise });
|
|
203
|
+
|
|
204
|
+
return promise;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { RefreshError } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AAD error codes that require the user to re-authenticate (invalid_grant family).
|
|
5
|
+
*
|
|
6
|
+
* 70000 — General invalid_grant
|
|
7
|
+
* 70008 — Refresh token has expired or is invalid
|
|
8
|
+
* 700082 — Refresh token has expired due to inactivity (90-day policy)
|
|
9
|
+
* 50173 — Fresh auth required; credential or token has expired
|
|
10
|
+
* 50057 — User account is disabled
|
|
11
|
+
* 65001 — The user or administrator has not consented to use the application
|
|
12
|
+
* 50076 — MFA required (interactive re-auth needed)
|
|
13
|
+
* 50079 — User is required to use MFA
|
|
14
|
+
* 50158 — External security challenge was not satisfied
|
|
15
|
+
* 53003 — Access blocked by Conditional Access
|
|
16
|
+
*/
|
|
17
|
+
const REAUTH_CODES = new Set([
|
|
18
|
+
70000, // General invalid_grant
|
|
19
|
+
70008, // Refresh token expired or invalid
|
|
20
|
+
700082, // Refresh token expired due to inactivity
|
|
21
|
+
50173, // Credential or token expired, fresh auth required
|
|
22
|
+
50057, // Account disabled
|
|
23
|
+
65001, // No consent granted
|
|
24
|
+
50076, // MFA required
|
|
25
|
+
50079, // MFA required (stronger auth)
|
|
26
|
+
50158, // External security challenge not satisfied
|
|
27
|
+
53003, // Access blocked by Conditional Access
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* AAD error codes that indicate a configuration problem (bad scopes, bad client).
|
|
32
|
+
*
|
|
33
|
+
* 70011 — The requested scope is invalid, unknown, or malformed. This is a
|
|
34
|
+
* deployment/configuration error, not an end-user reauth situation — the
|
|
35
|
+
* registered application's scope list must be corrected before retrying.
|
|
36
|
+
*/
|
|
37
|
+
const CONFIG_CODES = new Set([
|
|
38
|
+
70011, // Invalid, unknown, or malformed scope
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export interface MicrosoftTokenErrorResponse {
|
|
42
|
+
error: string;
|
|
43
|
+
error_description?: string;
|
|
44
|
+
error_codes?: number[];
|
|
45
|
+
correlation_id?: string;
|
|
46
|
+
trace_id?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Classifies a Microsoft OAuth error response into one of three kinds:
|
|
51
|
+
* - reauth-required: user must re-authenticate (invalid_grant family)
|
|
52
|
+
* - transient: network/server error, safe to retry
|
|
53
|
+
* - config: bad client credentials or revoked consent
|
|
54
|
+
*/
|
|
55
|
+
export function classifyMicrosoftError(
|
|
56
|
+
httpStatus: number,
|
|
57
|
+
body: MicrosoftTokenErrorResponse,
|
|
58
|
+
): RefreshError {
|
|
59
|
+
const errorCode = body.error ?? "unknown";
|
|
60
|
+
const errorCodes = body.error_codes ?? [];
|
|
61
|
+
|
|
62
|
+
// 1. explicit invalid_grant or known reauth error codes
|
|
63
|
+
if (
|
|
64
|
+
errorCode === "invalid_grant" ||
|
|
65
|
+
errorCodes.some((c) => REAUTH_CODES.has(c))
|
|
66
|
+
) {
|
|
67
|
+
return { kind: "reauth-required", code: errorCode };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. bad client credentials, revoked app consent, or misconfigured scopes
|
|
71
|
+
if (
|
|
72
|
+
errorCode === "invalid_client" ||
|
|
73
|
+
errorCode === "unauthorized_client" ||
|
|
74
|
+
errorCodes.some((c) => CONFIG_CODES.has(c))
|
|
75
|
+
) {
|
|
76
|
+
return { kind: "config", code: errorCode };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. HTTP 5xx or 429 (rate-limited) → transient
|
|
80
|
+
if (httpStatus >= 500 || httpStatus === 429) {
|
|
81
|
+
return { kind: "transient", code: errorCode };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 4. unknown → transient (retry-safe default)
|
|
85
|
+
return { kind: "transient", code: errorCode };
|
|
86
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface OAuthProviderConfig {
|
|
2
|
+
provider: "microsoft"; // union grows later
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
/** Default: https://login.microsoftonline.com/common/oauth2/v2.0/token */
|
|
6
|
+
tokenEndpoint: string;
|
|
7
|
+
/** Default: https://login.microsoftonline.com/common/oauth2/v2.0/authorize */
|
|
8
|
+
authorizationEndpoint: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TokenSet {
|
|
13
|
+
accessToken: string;
|
|
14
|
+
/** Epoch seconds */
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
/** Present when provider rotated the refresh token — caller MUST persist */
|
|
17
|
+
refreshToken?: string;
|
|
18
|
+
/** Present when the provider returns an OpenID Connect ID token (e.g. Microsoft
|
|
19
|
+
* when openid+email scopes are requested). Contains user identity claims. */
|
|
20
|
+
idToken?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RefreshError =
|
|
24
|
+
| { kind: "reauth-required"; code: string } // invalid_grant family
|
|
25
|
+
| { kind: "transient"; code: string } // network, 5xx, 429
|
|
26
|
+
| { kind: "config"; code: string }; // bad client secret, consent revoked
|
|
27
|
+
|
|
28
|
+
export class RefreshTokenError extends Error {
|
|
29
|
+
constructor(public readonly error: RefreshError) {
|
|
30
|
+
super(`OAuth refresh failed: ${error.kind} (${error.code})`);
|
|
31
|
+
this.name = "RefreshTokenError";
|
|
32
|
+
}
|
|
33
|
+
}
|