@remit/mail-oauth-service 0.0.7 → 0.0.8
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 +4 -1
- package/src/__tests__/microsoft-scopes.test.ts +153 -0
- package/src/index.ts +10 -8
- package/src/mail-oauth-service.ts +4 -0
- package/src/microsoft-scopes.ts +51 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mail-oauth-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
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
16
|
"test": "npm run test:typecheck && npm run test:run"
|
|
17
17
|
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@remit/domain-enums": "*"
|
|
20
|
+
},
|
|
18
21
|
"license": "MIT",
|
|
19
22
|
"publishConfig": {
|
|
20
23
|
"access": "public"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { AccountService } from "@remit/domain-enums";
|
|
4
|
+
import {
|
|
5
|
+
createMailOAuthService,
|
|
6
|
+
MICROSOFT_SERVICE_SCOPES,
|
|
7
|
+
microsoftProviderConfig,
|
|
8
|
+
microsoftScopes,
|
|
9
|
+
microsoftServicesGranted,
|
|
10
|
+
normalizeMicrosoftScopes,
|
|
11
|
+
} from "../index.js";
|
|
12
|
+
|
|
13
|
+
const IMAP = "https://outlook.office.com/IMAP.AccessAsUser.All";
|
|
14
|
+
const SMTP = "https://outlook.office.com/SMTP.Send";
|
|
15
|
+
const CALENDAR = "https://graph.microsoft.com/Calendars.Read";
|
|
16
|
+
const SESSION = ["offline_access", "openid", "email"];
|
|
17
|
+
|
|
18
|
+
describe("the Microsoft scopes a service selection asks for", () => {
|
|
19
|
+
test("mail asks for IMAP and SMTP and never the calendar", () => {
|
|
20
|
+
assert.deepEqual(microsoftScopes([AccountService.Mail]), [
|
|
21
|
+
IMAP,
|
|
22
|
+
SMTP,
|
|
23
|
+
...SESSION,
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("calendar asks for the Graph calendar scope and no mailbox access", () => {
|
|
28
|
+
assert.deepEqual(microsoftScopes([AccountService.Calendar]), [
|
|
29
|
+
CALENDAR,
|
|
30
|
+
...SESSION,
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("both put mail first whatever order they arrive in", () => {
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
microsoftScopes([
|
|
37
|
+
AccountService.Calendar,
|
|
38
|
+
AccountService.Mail,
|
|
39
|
+
AccountService.Calendar,
|
|
40
|
+
]),
|
|
41
|
+
[IMAP, SMTP, CALENDAR, ...SESSION],
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a provider config with no selection asks for mail, as before", () => {
|
|
46
|
+
const config = microsoftProviderConfig({
|
|
47
|
+
clientId: "client-id",
|
|
48
|
+
clientSecret: "client-secret",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(config.scopes, [IMAP, SMTP, ...SESSION]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the authorization URL carries the scopes of the selection", () => {
|
|
55
|
+
const service = createMailOAuthService(
|
|
56
|
+
microsoftProviderConfig({
|
|
57
|
+
clientId: "client-id",
|
|
58
|
+
clientSecret: "client-secret",
|
|
59
|
+
services: [AccountService.Calendar],
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const url = new URL(
|
|
64
|
+
service.buildAuthorizationUrl({
|
|
65
|
+
redirectUri: "https://app.example.com/callback",
|
|
66
|
+
state: "state",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
assert.equal(
|
|
71
|
+
url.searchParams.get("scope"),
|
|
72
|
+
[CALENDAR, ...SESSION].join(" "),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("the scopes Microsoft reports as granted", () => {
|
|
78
|
+
test("bare Graph names become full scope URIs and identity scopes drop out", () => {
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
normalizeMicrosoftScopes(
|
|
81
|
+
"Calendars.Read openid profile email offline_access",
|
|
82
|
+
),
|
|
83
|
+
[CALENDAR],
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("Outlook scopes keep their resource", () => {
|
|
88
|
+
assert.deepEqual(normalizeMicrosoftScopes(`${IMAP} ${SMTP}`), [IMAP, SMTP]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an absent scope field grants nothing", () => {
|
|
92
|
+
assert.deepEqual(normalizeMicrosoftScopes(undefined), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a service counts as granted only when every one of its scopes is", () => {
|
|
96
|
+
assert.deepEqual(microsoftServicesGranted([IMAP]), []);
|
|
97
|
+
assert.deepEqual(microsoftServicesGranted([IMAP, SMTP]), [
|
|
98
|
+
AccountService.Mail,
|
|
99
|
+
]);
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
microsoftServicesGranted([
|
|
102
|
+
CALENDAR.toLowerCase(),
|
|
103
|
+
...MICROSOFT_SERVICE_SCOPES[AccountService.Mail],
|
|
104
|
+
]),
|
|
105
|
+
[AccountService.Mail, AccountService.Calendar],
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the code exchange", () => {
|
|
111
|
+
let originalFetch: typeof globalThis.fetch;
|
|
112
|
+
let sentScope: string | null;
|
|
113
|
+
|
|
114
|
+
before(() => {
|
|
115
|
+
originalFetch = globalThis.fetch;
|
|
116
|
+
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
117
|
+
sentScope = new URLSearchParams(String(init?.body)).get("scope");
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
status: 200,
|
|
121
|
+
json: async () => ({
|
|
122
|
+
access_token: "access",
|
|
123
|
+
expires_in: 3600,
|
|
124
|
+
refresh_token: "refresh",
|
|
125
|
+
scope: "Calendars.Read openid email",
|
|
126
|
+
}),
|
|
127
|
+
} as Response;
|
|
128
|
+
}) as typeof globalThis.fetch;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
after(() => {
|
|
132
|
+
globalThis.fetch = originalFetch;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("redeems the code for the selection's scopes and reports what was granted", async () => {
|
|
136
|
+
const service = createMailOAuthService(
|
|
137
|
+
microsoftProviderConfig({
|
|
138
|
+
clientId: "client-id",
|
|
139
|
+
clientSecret: "client-secret",
|
|
140
|
+
services: [AccountService.Calendar],
|
|
141
|
+
overrides: { tokenEndpoint: "https://example.com/token" },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const tokenSet = await service.exchangeCode(
|
|
146
|
+
"code",
|
|
147
|
+
"https://app.example.com/callback",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
assert.equal(sentScope, [CALENDAR, ...SESSION].join(" "));
|
|
151
|
+
assert.deepEqual(tokenSet.grantedScopes, [CALENDAR]);
|
|
152
|
+
});
|
|
153
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
export * from "./mail-oauth-service.js";
|
|
2
2
|
export * from "./microsoft-errors.js";
|
|
3
|
+
export * from "./microsoft-scopes.js";
|
|
3
4
|
export * from "./types.js";
|
|
4
5
|
|
|
6
|
+
import { AccountService } from "@remit/domain-enums";
|
|
7
|
+
import {
|
|
8
|
+
type AccountServiceName,
|
|
9
|
+
microsoftScopes,
|
|
10
|
+
} from "./microsoft-scopes.js";
|
|
5
11
|
import type { OAuthProviderConfig } from "./types.js";
|
|
6
12
|
|
|
7
13
|
const MICROSOFT_TOKEN_ENDPOINT =
|
|
8
14
|
"https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
9
15
|
const MICROSOFT_AUTHORIZATION_ENDPOINT =
|
|
10
16
|
"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
17
|
|
|
19
18
|
export function microsoftProviderConfig(opts: {
|
|
20
19
|
clientId: string;
|
|
21
20
|
clientSecret: string;
|
|
21
|
+
services?: readonly AccountServiceName[];
|
|
22
22
|
overrides?: Partial<
|
|
23
23
|
Pick<
|
|
24
24
|
OAuthProviderConfig,
|
|
@@ -33,6 +33,8 @@ export function microsoftProviderConfig(opts: {
|
|
|
33
33
|
tokenEndpoint: opts.overrides?.tokenEndpoint ?? MICROSOFT_TOKEN_ENDPOINT,
|
|
34
34
|
authorizationEndpoint:
|
|
35
35
|
opts.overrides?.authorizationEndpoint ?? MICROSOFT_AUTHORIZATION_ENDPOINT,
|
|
36
|
-
scopes:
|
|
36
|
+
scopes:
|
|
37
|
+
opts.overrides?.scopes ??
|
|
38
|
+
microsoftScopes(opts.services ?? [AccountService.Mail]),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
classifyMicrosoftError,
|
|
3
3
|
type MicrosoftTokenErrorResponse,
|
|
4
4
|
} from "./microsoft-errors.js";
|
|
5
|
+
import { normalizeMicrosoftScopes } from "./microsoft-scopes.js";
|
|
5
6
|
import {
|
|
6
7
|
type OAuthProviderConfig,
|
|
7
8
|
RefreshTokenError,
|
|
@@ -50,6 +51,7 @@ interface RawTokenResponse {
|
|
|
50
51
|
expires_in: number;
|
|
51
52
|
refresh_token?: string;
|
|
52
53
|
id_token?: string;
|
|
54
|
+
scope?: string;
|
|
53
55
|
error?: string;
|
|
54
56
|
error_codes?: number[];
|
|
55
57
|
error_description?: string;
|
|
@@ -70,6 +72,7 @@ function parseTokenResponse(
|
|
|
70
72
|
return {
|
|
71
73
|
accessToken: raw.access_token,
|
|
72
74
|
expiresAt: Math.floor(Date.now() / 1000) + raw.expires_in,
|
|
75
|
+
grantedScopes: normalizeMicrosoftScopes(raw.scope),
|
|
73
76
|
...(raw.refresh_token ? { refreshToken: raw.refresh_token } : {}),
|
|
74
77
|
...(raw.id_token ? { idToken: raw.id_token } : {}),
|
|
75
78
|
};
|
|
@@ -147,6 +150,7 @@ export function createMailOAuthService(
|
|
|
147
150
|
redirect_uri: redirectUri,
|
|
148
151
|
client_id: config.clientId,
|
|
149
152
|
client_secret: config.clientSecret,
|
|
153
|
+
scope: config.scopes.join(" "),
|
|
150
154
|
};
|
|
151
155
|
if (codeVerifier) {
|
|
152
156
|
params.code_verifier = codeVerifier;
|
|
@@ -0,0 +1,51 @@
|
|
|
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
CHANGED
|
@@ -18,6 +18,7 @@ export interface TokenSet {
|
|
|
18
18
|
/** Present when the provider returns an OpenID Connect ID token (e.g. Microsoft
|
|
19
19
|
* when openid+email scopes are requested). Contains user identity claims. */
|
|
20
20
|
idToken?: string;
|
|
21
|
+
grantedScopes: string[];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export type RefreshError =
|