@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.
@@ -0,0 +1,12 @@
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
+ import { type AccountServiceName } from "./microsoft-scopes.js";
6
+ import type { OAuthProviderConfig } from "./types.js";
7
+ export declare function microsoftProviderConfig(opts: {
8
+ clientId: string;
9
+ clientSecret: string;
10
+ services?: readonly AccountServiceName[];
11
+ overrides?: Partial<Pick<OAuthProviderConfig, "tokenEndpoint" | "authorizationEndpoint" | "scopes">>;
12
+ }): OAuthProviderConfig;
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
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
+ import { AccountService } from "@remit/domain-enums";
6
+ import { microsoftScopes, } from "./microsoft-scopes.js";
7
+ const MICROSOFT_TOKEN_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
8
+ const MICROSOFT_AUTHORIZATION_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
9
+ export function microsoftProviderConfig(opts) {
10
+ return {
11
+ provider: "microsoft",
12
+ clientId: opts.clientId,
13
+ clientSecret: opts.clientSecret,
14
+ tokenEndpoint: opts.overrides?.tokenEndpoint ?? MICROSOFT_TOKEN_ENDPOINT,
15
+ authorizationEndpoint: opts.overrides?.authorizationEndpoint ?? MICROSOFT_AUTHORIZATION_ENDPOINT,
16
+ scopes: opts.overrides?.scopes ??
17
+ microsoftScopes(opts.services ?? [AccountService.Mail]),
18
+ };
19
+ }
@@ -0,0 +1,23 @@
1
+ import { type OAuthProviderConfig, type TokenSet } from "./types.js";
2
+ export interface MailOAuthService {
3
+ /** Exchange an authorization code for a token set. */
4
+ exchangeCode(code: string, redirectUri: string, codeVerifier?: string): Promise<TokenSet>;
5
+ /** Refresh an access token using a refresh token. Throws RefreshTokenError on failure. */
6
+ refresh(refreshToken: string): Promise<TokenSet>;
7
+ /** Build the OAuth2 authorization URL to redirect the user to. */
8
+ buildAuthorizationUrl(opts: {
9
+ redirectUri: string;
10
+ state: string;
11
+ loginHint?: string;
12
+ }): string;
13
+ /**
14
+ * Return a valid access token for the given cache key, refreshing if needed.
15
+ * Concurrent callers for the same key share a single in-flight refresh (single-flight).
16
+ *
17
+ * Rotation contract: if the provider rotates the refresh token, the new value is
18
+ * surfaced in the returned `TokenSet.refreshToken`. The caller is responsible for
19
+ * persisting it — this service never persists tokens.
20
+ */
21
+ getAccessToken(cacheKey: string, refreshToken: string): Promise<TokenSet>;
22
+ }
23
+ export declare function createMailOAuthService(config: OAuthProviderConfig): MailOAuthService;
@@ -0,0 +1,128 @@
1
+ import { classifyMicrosoftError, } from "./microsoft-errors.js";
2
+ import { normalizeMicrosoftScopes } from "./microsoft-scopes.js";
3
+ import { RefreshTokenError, } from "./types.js";
4
+ /** Tokens are refreshed 5 minutes before expiry. */
5
+ const EXPIRY_BUFFER_SECS = 5 * 60;
6
+ function parseTokenResponse(httpStatus, raw) {
7
+ if (raw.error) {
8
+ const classified = classifyMicrosoftError(httpStatus, raw);
9
+ throw new RefreshTokenError(classified);
10
+ }
11
+ return {
12
+ accessToken: raw.access_token,
13
+ expiresAt: Math.floor(Date.now() / 1000) + raw.expires_in,
14
+ grantedScopes: normalizeMicrosoftScopes(raw.scope),
15
+ ...(raw.refresh_token ? { refreshToken: raw.refresh_token } : {}),
16
+ ...(raw.id_token ? { idToken: raw.id_token } : {}),
17
+ };
18
+ }
19
+ async function postForm(url, params) {
20
+ const body = new URLSearchParams(params).toString();
21
+ let response;
22
+ try {
23
+ response = await fetch(url, {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
26
+ body,
27
+ });
28
+ }
29
+ catch (_cause) {
30
+ // network error → transient
31
+ throw new RefreshTokenError({ kind: "transient", code: "network_error" });
32
+ }
33
+ let raw;
34
+ try {
35
+ raw = (await response.json());
36
+ }
37
+ catch {
38
+ throw new RefreshTokenError({
39
+ kind: "transient",
40
+ code: "invalid_response",
41
+ });
42
+ }
43
+ if (!response.ok && !raw.error) {
44
+ // Non-OK HTTP with no error body → treat as transient
45
+ throw new RefreshTokenError({
46
+ kind: "transient",
47
+ code: `http_${response.status}`,
48
+ });
49
+ }
50
+ return parseTokenResponse(response.status, raw);
51
+ }
52
+ function isExpiringSoon(tokenSet) {
53
+ const nowSecs = Math.floor(Date.now() / 1000);
54
+ return tokenSet.expiresAt - nowSecs < EXPIRY_BUFFER_SECS;
55
+ }
56
+ export function createMailOAuthService(config) {
57
+ const cache = new Map();
58
+ return {
59
+ buildAuthorizationUrl({ redirectUri, state, loginHint }) {
60
+ const url = new URL(config.authorizationEndpoint);
61
+ url.searchParams.set("client_id", config.clientId);
62
+ url.searchParams.set("response_type", "code");
63
+ url.searchParams.set("scope", config.scopes.join(" "));
64
+ url.searchParams.set("redirect_uri", redirectUri);
65
+ url.searchParams.set("state", state);
66
+ url.searchParams.set("access_type", "offline"); // Google compat
67
+ url.searchParams.set("prompt", "consent"); // reliably get refresh token
68
+ if (loginHint) {
69
+ url.searchParams.set("login_hint", loginHint);
70
+ }
71
+ return url.toString();
72
+ },
73
+ async exchangeCode(code, redirectUri, codeVerifier) {
74
+ const params = {
75
+ grant_type: "authorization_code",
76
+ code,
77
+ redirect_uri: redirectUri,
78
+ client_id: config.clientId,
79
+ client_secret: config.clientSecret,
80
+ scope: config.scopes.join(" "),
81
+ };
82
+ if (codeVerifier) {
83
+ params.code_verifier = codeVerifier;
84
+ }
85
+ return postForm(config.tokenEndpoint, params);
86
+ },
87
+ async refresh(refreshToken) {
88
+ return postForm(config.tokenEndpoint, {
89
+ grant_type: "refresh_token",
90
+ refresh_token: refreshToken,
91
+ client_id: config.clientId,
92
+ client_secret: config.clientSecret,
93
+ scope: config.scopes.join(" "),
94
+ });
95
+ },
96
+ async getAccessToken(cacheKey, refreshToken) {
97
+ const entry = cache.get(cacheKey);
98
+ // Return cached token if still valid
99
+ if (entry?.tokenSet && !isExpiringSoon(entry.tokenSet)) {
100
+ return entry.tokenSet;
101
+ }
102
+ // Single-flight: if a refresh is already in-flight for this key, await it
103
+ if (entry?.promise) {
104
+ return entry.promise;
105
+ }
106
+ // Start a new refresh
107
+ const promise = postForm(config.tokenEndpoint, {
108
+ grant_type: "refresh_token",
109
+ refresh_token: refreshToken,
110
+ client_id: config.clientId,
111
+ client_secret: config.clientSecret,
112
+ scope: config.scopes.join(" "),
113
+ }).then((tokenSet) => {
114
+ cache.set(cacheKey, { tokenSet });
115
+ return tokenSet;
116
+ }, (err) => {
117
+ // Clear the promise so next caller tries again
118
+ const current = cache.get(cacheKey);
119
+ if (current?.promise === promise) {
120
+ cache.delete(cacheKey);
121
+ }
122
+ throw err;
123
+ });
124
+ cache.set(cacheKey, { tokenSet: entry?.tokenSet, promise });
125
+ return promise;
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,15 @@
1
+ import type { RefreshError } from "./types.js";
2
+ export interface MicrosoftTokenErrorResponse {
3
+ error: string;
4
+ error_description?: string;
5
+ error_codes?: number[];
6
+ correlation_id?: string;
7
+ trace_id?: string;
8
+ }
9
+ /**
10
+ * Classifies a Microsoft OAuth error response into one of three kinds:
11
+ * - reauth-required: user must re-authenticate (invalid_grant family)
12
+ * - transient: network/server error, safe to retry
13
+ * - config: bad client credentials or revoked consent
14
+ */
15
+ export declare function classifyMicrosoftError(httpStatus: number, body: MicrosoftTokenErrorResponse): RefreshError;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * AAD error codes that require the user to re-authenticate (invalid_grant family).
3
+ *
4
+ * 70000 — General invalid_grant
5
+ * 70008 — Refresh token has expired or is invalid
6
+ * 700082 — Refresh token has expired due to inactivity (90-day policy)
7
+ * 50173 — Fresh auth required; credential or token has expired
8
+ * 50057 — User account is disabled
9
+ * 65001 — The user or administrator has not consented to use the application
10
+ * 50076 — MFA required (interactive re-auth needed)
11
+ * 50079 — User is required to use MFA
12
+ * 50158 — External security challenge was not satisfied
13
+ * 53003 — Access blocked by Conditional Access
14
+ */
15
+ const REAUTH_CODES = new Set([
16
+ 70000, // General invalid_grant
17
+ 70008, // Refresh token expired or invalid
18
+ 700082, // Refresh token expired due to inactivity
19
+ 50173, // Credential or token expired, fresh auth required
20
+ 50057, // Account disabled
21
+ 65001, // No consent granted
22
+ 50076, // MFA required
23
+ 50079, // MFA required (stronger auth)
24
+ 50158, // External security challenge not satisfied
25
+ 53003, // Access blocked by Conditional Access
26
+ ]);
27
+ /**
28
+ * AAD error codes that indicate a configuration problem (bad scopes, bad client).
29
+ *
30
+ * 70011 — The requested scope is invalid, unknown, or malformed. This is a
31
+ * deployment/configuration error, not an end-user reauth situation — the
32
+ * registered application's scope list must be corrected before retrying.
33
+ */
34
+ const CONFIG_CODES = new Set([
35
+ 70011, // Invalid, unknown, or malformed scope
36
+ ]);
37
+ /**
38
+ * Classifies a Microsoft OAuth error response into one of three kinds:
39
+ * - reauth-required: user must re-authenticate (invalid_grant family)
40
+ * - transient: network/server error, safe to retry
41
+ * - config: bad client credentials or revoked consent
42
+ */
43
+ export function classifyMicrosoftError(httpStatus, body) {
44
+ const errorCode = body.error ?? "unknown";
45
+ const errorCodes = body.error_codes ?? [];
46
+ // 1. explicit invalid_grant or known reauth error codes
47
+ if (errorCode === "invalid_grant" ||
48
+ errorCodes.some((c) => REAUTH_CODES.has(c))) {
49
+ return { kind: "reauth-required", code: errorCode };
50
+ }
51
+ // 2. bad client credentials, revoked app consent, or misconfigured scopes
52
+ if (errorCode === "invalid_client" ||
53
+ errorCode === "unauthorized_client" ||
54
+ errorCodes.some((c) => CONFIG_CODES.has(c))) {
55
+ return { kind: "config", code: errorCode };
56
+ }
57
+ // 3. HTTP 5xx or 429 (rate-limited) → transient
58
+ if (httpStatus >= 500 || httpStatus === 429) {
59
+ return { kind: "transient", code: errorCode };
60
+ }
61
+ // 4. unknown → transient (retry-safe default)
62
+ return { kind: "transient", code: errorCode };
63
+ }
@@ -0,0 +1,7 @@
1
+ import { AccountService } from "@remit/domain-enums";
2
+ export type AccountServiceName = (typeof AccountService)[keyof typeof AccountService];
3
+ export declare const MICROSOFT_SERVICE_SCOPES: Readonly<Record<AccountServiceName, readonly string[]>>;
4
+ export declare const orderServices: (services: readonly AccountServiceName[]) => AccountServiceName[];
5
+ export declare const microsoftScopes: (services: readonly AccountServiceName[]) => string[];
6
+ export declare const normalizeMicrosoftScopes: (scope: string | undefined) => string[];
7
+ export declare const microsoftServicesGranted: (grantedScopes: readonly string[]) => AccountServiceName[];
@@ -0,0 +1,24 @@
1
+ import { AccountService } from "@remit/domain-enums";
2
+ const GRAPH_RESOURCE = "https://graph.microsoft.com/";
3
+ const SESSION_SCOPES = ["offline_access", "openid", "email"];
4
+ const IDENTITY_SCOPES = new Set([...SESSION_SCOPES, "profile"]);
5
+ export const MICROSOFT_SERVICE_SCOPES = {
6
+ [AccountService.Mail]: [
7
+ "https://outlook.office.com/IMAP.AccessAsUser.All",
8
+ "https://outlook.office.com/SMTP.Send",
9
+ ],
10
+ [AccountService.Calendar]: [`${GRAPH_RESOURCE}Calendars.Read`],
11
+ };
12
+ export const orderServices = (services) => Object.values(AccountService).filter((service) => services.includes(service));
13
+ export const microsoftScopes = (services) => [
14
+ ...orderServices(services).flatMap((service) => MICROSOFT_SERVICE_SCOPES[service]),
15
+ ...SESSION_SCOPES,
16
+ ];
17
+ export const normalizeMicrosoftScopes = (scope) => (scope ?? "")
18
+ .split(" ")
19
+ .filter((name) => name.length > 0 && !IDENTITY_SCOPES.has(name))
20
+ .map((name) => (name.includes("://") ? name : `${GRAPH_RESOURCE}${name}`));
21
+ export const microsoftServicesGranted = (grantedScopes) => {
22
+ const granted = new Set(grantedScopes.map((scope) => scope.toLowerCase()));
23
+ return Object.values(AccountService).filter((service) => MICROSOFT_SERVICE_SCOPES[service].every((scope) => granted.has(scope.toLowerCase())));
24
+ };
@@ -0,0 +1,35 @@
1
+ export interface OAuthProviderConfig {
2
+ provider: "microsoft";
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
+ export interface TokenSet {
12
+ accessToken: string;
13
+ /** Epoch seconds */
14
+ expiresAt: number;
15
+ /** Present when provider rotated the refresh token — caller MUST persist */
16
+ refreshToken?: string;
17
+ /** Present when the provider returns an OpenID Connect ID token (e.g. Microsoft
18
+ * when openid+email scopes are requested). Contains user identity claims. */
19
+ idToken?: string;
20
+ grantedScopes: string[];
21
+ }
22
+ export type RefreshError = {
23
+ kind: "reauth-required";
24
+ code: string;
25
+ } | {
26
+ kind: "transient";
27
+ code: string;
28
+ } | {
29
+ kind: "config";
30
+ code: string;
31
+ };
32
+ export declare class RefreshTokenError extends Error {
33
+ readonly error: RefreshError;
34
+ constructor(error: RefreshError);
35
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export class RefreshTokenError extends Error {
2
+ error;
3
+ constructor(error) {
4
+ super(`OAuth refresh failed: ${error.kind} (${error.code})`);
5
+ this.error = error;
6
+ this.name = "RefreshTokenError";
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,30 +1,35 @@
1
1
  {
2
- "name": "@remit/mail-oauth-service",
3
- "version": "0.0.8",
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 $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=96 --test 'src/**/*.test.ts'",
16
- "test": "npm run test:typecheck && npm run test:run"
17
- },
18
- "dependencies": {
19
- "@remit/domain-enums": "*"
20
- },
21
- "license": "MIT",
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/remit-mail/reader.git",
28
- "directory": "packages/mail-oauth-service"
29
- }
2
+ "name": "@remit/mail-oauth-service",
3
+ "version": "0.0.9",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "prepack": "node ../../npm-scripts/pack-dist.mjs prepack",
15
+ "postpack": "node ../../npm-scripts/pack-dist.mjs postpack",
16
+ "test:typecheck": "tsgo --noEmit",
17
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=96 --test 'src/**/*.test.ts'",
18
+ "test": "npm run test:typecheck && npm run test:run"
19
+ },
20
+ "dependencies": {
21
+ "@remit/domain-enums": "*"
22
+ },
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/remit-mail/reader.git",
30
+ "directory": "packages/mail-oauth-service"
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ]
30
35
  }