@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.
- package/dist/index.d.ts +12 -0
- package/dist/index.js +19 -0
- package/dist/mail-oauth-service.d.ts +23 -0
- package/dist/mail-oauth-service.js +128 -0
- package/dist/microsoft-errors.d.ts +15 -0
- package/dist/microsoft-errors.js +63 -0
- package/dist/microsoft-scopes.d.ts +7 -0
- package/dist/microsoft-scopes.js +24 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +8 -0
- package/package.json +33 -28
- package/src/__tests__/mail-oauth-service.test.ts +0 -398
- package/src/__tests__/microsoft-scopes.test.ts +0 -153
- package/src/index.ts +0 -40
- package/src/mail-oauth-service.ts +0 -211
- package/src/microsoft-errors.ts +0 -86
- package/src/microsoft-scopes.ts +0 -51
- package/src/types.ts +0 -34
- package/tsconfig.json +0 -8
|
@@ -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
|
-
}
|
package/src/microsoft-errors.ts
DELETED
|
@@ -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
|
-
}
|
package/src/microsoft-scopes.ts
DELETED
|
@@ -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
|
-
}
|