@remit/mail-oauth-service 0.0.8 → 0.0.10

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,211 +0,0 @@
1
- import {
2
- classifyMicrosoftError,
3
- type MicrosoftTokenErrorResponse,
4
- } from "./microsoft-errors.js";
5
- import { normalizeMicrosoftScopes } from "./microsoft-scopes.js";
6
- import {
7
- type OAuthProviderConfig,
8
- RefreshTokenError,
9
- type TokenSet,
10
- } from "./types.js";
11
-
12
- export interface MailOAuthService {
13
- /** Exchange an authorization code for a token set. */
14
- exchangeCode(
15
- code: string,
16
- redirectUri: string,
17
- codeVerifier?: string,
18
- ): Promise<TokenSet>;
19
-
20
- /** Refresh an access token using a refresh token. Throws RefreshTokenError on failure. */
21
- refresh(refreshToken: string): Promise<TokenSet>;
22
-
23
- /** Build the OAuth2 authorization URL to redirect the user to. */
24
- buildAuthorizationUrl(opts: {
25
- redirectUri: string;
26
- state: string;
27
- loginHint?: string;
28
- }): string;
29
-
30
- /**
31
- * Return a valid access token for the given cache key, refreshing if needed.
32
- * Concurrent callers for the same key share a single in-flight refresh (single-flight).
33
- *
34
- * Rotation contract: if the provider rotates the refresh token, the new value is
35
- * surfaced in the returned `TokenSet.refreshToken`. The caller is responsible for
36
- * persisting it — this service never persists tokens.
37
- */
38
- getAccessToken(cacheKey: string, refreshToken: string): Promise<TokenSet>;
39
- }
40
-
41
- /** Tokens are refreshed 5 minutes before expiry. */
42
- const EXPIRY_BUFFER_SECS = 5 * 60;
43
-
44
- interface CacheEntry {
45
- tokenSet?: TokenSet;
46
- promise?: Promise<TokenSet>;
47
- }
48
-
49
- interface RawTokenResponse {
50
- access_token: string;
51
- expires_in: number;
52
- refresh_token?: string;
53
- id_token?: string;
54
- scope?: string;
55
- error?: string;
56
- error_codes?: number[];
57
- error_description?: string;
58
- }
59
-
60
- function parseTokenResponse(
61
- httpStatus: number,
62
- raw: RawTokenResponse,
63
- ): TokenSet {
64
- if (raw.error) {
65
- const classified = classifyMicrosoftError(
66
- httpStatus,
67
- raw as MicrosoftTokenErrorResponse,
68
- );
69
- throw new RefreshTokenError(classified);
70
- }
71
-
72
- return {
73
- accessToken: raw.access_token,
74
- expiresAt: Math.floor(Date.now() / 1000) + raw.expires_in,
75
- grantedScopes: normalizeMicrosoftScopes(raw.scope),
76
- ...(raw.refresh_token ? { refreshToken: raw.refresh_token } : {}),
77
- ...(raw.id_token ? { idToken: raw.id_token } : {}),
78
- };
79
- }
80
-
81
- async function postForm(
82
- url: string,
83
- params: Record<string, string>,
84
- ): Promise<TokenSet> {
85
- const body = new URLSearchParams(params).toString();
86
-
87
- let response: Response;
88
- try {
89
- response = await fetch(url, {
90
- method: "POST",
91
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
92
- body,
93
- });
94
- } catch (_cause) {
95
- // network error → transient
96
- throw new RefreshTokenError({ kind: "transient", code: "network_error" });
97
- }
98
-
99
- let raw: RawTokenResponse;
100
- try {
101
- raw = (await response.json()) as RawTokenResponse;
102
- } catch {
103
- throw new RefreshTokenError({
104
- kind: "transient",
105
- code: "invalid_response",
106
- });
107
- }
108
-
109
- if (!response.ok && !raw.error) {
110
- // Non-OK HTTP with no error body → treat as transient
111
- throw new RefreshTokenError({
112
- kind: "transient",
113
- code: `http_${response.status}`,
114
- });
115
- }
116
-
117
- return parseTokenResponse(response.status, raw);
118
- }
119
-
120
- function isExpiringSoon(tokenSet: TokenSet): boolean {
121
- const nowSecs = Math.floor(Date.now() / 1000);
122
- return tokenSet.expiresAt - nowSecs < EXPIRY_BUFFER_SECS;
123
- }
124
-
125
- export function createMailOAuthService(
126
- config: OAuthProviderConfig,
127
- ): MailOAuthService {
128
- const cache = new Map<string, CacheEntry>();
129
-
130
- return {
131
- buildAuthorizationUrl({ redirectUri, state, loginHint }) {
132
- const url = new URL(config.authorizationEndpoint);
133
- url.searchParams.set("client_id", config.clientId);
134
- url.searchParams.set("response_type", "code");
135
- url.searchParams.set("scope", config.scopes.join(" "));
136
- url.searchParams.set("redirect_uri", redirectUri);
137
- url.searchParams.set("state", state);
138
- url.searchParams.set("access_type", "offline"); // Google compat
139
- url.searchParams.set("prompt", "consent"); // reliably get refresh token
140
- if (loginHint) {
141
- url.searchParams.set("login_hint", loginHint);
142
- }
143
- return url.toString();
144
- },
145
-
146
- async exchangeCode(code, redirectUri, codeVerifier) {
147
- const params: Record<string, string> = {
148
- grant_type: "authorization_code",
149
- code,
150
- redirect_uri: redirectUri,
151
- client_id: config.clientId,
152
- client_secret: config.clientSecret,
153
- scope: config.scopes.join(" "),
154
- };
155
- if (codeVerifier) {
156
- params.code_verifier = codeVerifier;
157
- }
158
- return postForm(config.tokenEndpoint, params);
159
- },
160
-
161
- async refresh(refreshToken) {
162
- return postForm(config.tokenEndpoint, {
163
- grant_type: "refresh_token",
164
- refresh_token: refreshToken,
165
- client_id: config.clientId,
166
- client_secret: config.clientSecret,
167
- scope: config.scopes.join(" "),
168
- });
169
- },
170
-
171
- async getAccessToken(cacheKey, refreshToken) {
172
- const entry = cache.get(cacheKey);
173
-
174
- // Return cached token if still valid
175
- if (entry?.tokenSet && !isExpiringSoon(entry.tokenSet)) {
176
- return entry.tokenSet;
177
- }
178
-
179
- // Single-flight: if a refresh is already in-flight for this key, await it
180
- if (entry?.promise) {
181
- return entry.promise;
182
- }
183
-
184
- // Start a new refresh
185
- const promise = postForm(config.tokenEndpoint, {
186
- grant_type: "refresh_token",
187
- refresh_token: refreshToken,
188
- client_id: config.clientId,
189
- client_secret: config.clientSecret,
190
- scope: config.scopes.join(" "),
191
- }).then(
192
- (tokenSet) => {
193
- cache.set(cacheKey, { tokenSet });
194
- return tokenSet;
195
- },
196
- (err) => {
197
- // Clear the promise so next caller tries again
198
- const current = cache.get(cacheKey);
199
- if (current?.promise === promise) {
200
- cache.delete(cacheKey);
201
- }
202
- throw err;
203
- },
204
- );
205
-
206
- cache.set(cacheKey, { tokenSet: entry?.tokenSet, promise });
207
-
208
- return promise;
209
- },
210
- };
211
- }
@@ -1,86 +0,0 @@
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
- }
@@ -1,51 +0,0 @@
1
- import { AccountService } from "@remit/domain-enums";
2
-
3
- export type AccountServiceName =
4
- (typeof AccountService)[keyof typeof AccountService];
5
-
6
- const GRAPH_RESOURCE = "https://graph.microsoft.com/";
7
-
8
- const SESSION_SCOPES = ["offline_access", "openid", "email"];
9
-
10
- const IDENTITY_SCOPES = new Set([...SESSION_SCOPES, "profile"]);
11
-
12
- export const MICROSOFT_SERVICE_SCOPES: Readonly<
13
- Record<AccountServiceName, readonly string[]>
14
- > = {
15
- [AccountService.Mail]: [
16
- "https://outlook.office.com/IMAP.AccessAsUser.All",
17
- "https://outlook.office.com/SMTP.Send",
18
- ],
19
- [AccountService.Calendar]: [`${GRAPH_RESOURCE}Calendars.Read`],
20
- };
21
-
22
- export const orderServices = (
23
- services: readonly AccountServiceName[],
24
- ): AccountServiceName[] =>
25
- Object.values(AccountService).filter((service) => services.includes(service));
26
-
27
- export const microsoftScopes = (
28
- services: readonly AccountServiceName[],
29
- ): string[] => [
30
- ...orderServices(services).flatMap(
31
- (service) => MICROSOFT_SERVICE_SCOPES[service],
32
- ),
33
- ...SESSION_SCOPES,
34
- ];
35
-
36
- export const normalizeMicrosoftScopes = (scope: string | undefined): string[] =>
37
- (scope ?? "")
38
- .split(" ")
39
- .filter((name) => name.length > 0 && !IDENTITY_SCOPES.has(name))
40
- .map((name) => (name.includes("://") ? name : `${GRAPH_RESOURCE}${name}`));
41
-
42
- export const microsoftServicesGranted = (
43
- grantedScopes: readonly string[],
44
- ): AccountServiceName[] => {
45
- const granted = new Set(grantedScopes.map((scope) => scope.toLowerCase()));
46
- return Object.values(AccountService).filter((service) =>
47
- MICROSOFT_SERVICE_SCOPES[service].every((scope) =>
48
- granted.has(scope.toLowerCase()),
49
- ),
50
- );
51
- };
package/src/types.ts DELETED
@@ -1,34 +0,0 @@
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
- grantedScopes: string[];
22
- }
23
-
24
- export type RefreshError =
25
- | { kind: "reauth-required"; code: string } // invalid_grant family
26
- | { kind: "transient"; code: string } // network, 5xx, 429
27
- | { kind: "config"; code: string }; // bad client secret, consent revoked
28
-
29
- export class RefreshTokenError extends Error {
30
- constructor(public readonly error: RefreshError) {
31
- super(`OAuth refresh failed: ${error.kind} (${error.code})`);
32
- this.name = "RefreshTokenError";
33
- }
34
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src"
6
- },
7
- "include": ["src/**/*.ts"]
8
- }