@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 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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }