@remit/backend 0.0.126 → 0.0.128
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 +1 -1
- package/src/handlers/account-guards.ts +11 -13
- package/src/handlers/account-oauth-callback.test.ts +9 -1
- package/src/handlers/account-oauth-consent.test.ts +336 -0
- package/src/handlers/account-oauth.test.ts +56 -0
- package/src/handlers/account-oauth.ts +148 -24
- package/src/handlers/account-synced-services.test.ts +34 -6
- package/src/handlers/account.ts +2 -2
- package/src/handlers/mailbox.ts +1 -0
package/package.json
CHANGED
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
import type { AccountItem } from "@remit/data-ports";
|
|
7
7
|
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
8
8
|
import { AccountAuthType, AccountService } from "@remit/domain-enums";
|
|
9
|
+
import {
|
|
10
|
+
microsoftServicesGranted,
|
|
11
|
+
orderServices,
|
|
12
|
+
} from "@remit/mail-oauth-service";
|
|
9
13
|
import type { AccountOverrides } from "./account-overrides.js";
|
|
10
14
|
import type { AccountSignature } from "./account-signature.js";
|
|
11
15
|
|
|
@@ -85,23 +89,17 @@ export const assertPasswordProvided = (
|
|
|
85
89
|
}
|
|
86
90
|
};
|
|
87
91
|
|
|
88
|
-
const MICROSOFT_GRANT_SERVICES: readonly AccountServiceName[] = [
|
|
89
|
-
AccountService.Mail,
|
|
90
|
-
];
|
|
91
|
-
|
|
92
92
|
export const resolveSyncedServices = (
|
|
93
|
-
|
|
93
|
+
account: Pick<AccountItem, "authType" | "grantedScopes">,
|
|
94
94
|
requested: readonly AccountServiceName[],
|
|
95
95
|
): AccountServiceName[] => {
|
|
96
|
-
const services =
|
|
97
|
-
requested.includes(service),
|
|
98
|
-
);
|
|
96
|
+
const services = orderServices(requested);
|
|
99
97
|
if (services.length === 0) {
|
|
100
98
|
throw new BadRequestError(
|
|
101
99
|
"syncedServices must name at least one service. To stop syncing an account entirely, delete it with DELETE /accounts/{accountId}.",
|
|
102
100
|
);
|
|
103
101
|
}
|
|
104
|
-
if (authType !== AccountAuthType.OauthMicrosoft) {
|
|
102
|
+
if (account.authType !== AccountAuthType.OauthMicrosoft) {
|
|
105
103
|
if (services.some((service) => service !== AccountService.Mail)) {
|
|
106
104
|
throw new BadRequestError(
|
|
107
105
|
`A password account syncs mail only, so syncedServices must be ["${AccountService.Mail}"].`,
|
|
@@ -109,12 +107,11 @@ export const resolveSyncedServices = (
|
|
|
109
107
|
}
|
|
110
108
|
return services;
|
|
111
109
|
}
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
);
|
|
110
|
+
const granted = microsoftServicesGranted(account.grantedScopes);
|
|
111
|
+
const uncovered = services.filter((service) => !granted.includes(service));
|
|
115
112
|
if (uncovered.length > 0) {
|
|
116
113
|
throw new BadRequestError(
|
|
117
|
-
`This account's
|
|
114
|
+
`This account's granted scopes lack ${uncovered.join(", ")}. Consent to them through POST /accounts/oauth/microsoft/start with services ${JSON.stringify(services)}, then sign in to Microsoft again.`,
|
|
118
115
|
);
|
|
119
116
|
}
|
|
120
117
|
return services;
|
|
@@ -140,6 +137,7 @@ export const toAccountResponse = (
|
|
|
140
137
|
email: account.email,
|
|
141
138
|
authType: account.authType ?? AccountAuthType.Password,
|
|
142
139
|
syncedServices: account.syncedServices,
|
|
140
|
+
grantedScopes: account.grantedScopes,
|
|
143
141
|
imapHost: account.imapHost,
|
|
144
142
|
imapPort: account.imapPort,
|
|
145
143
|
imapTls: account.imapTls,
|
|
@@ -14,6 +14,7 @@ import { randomUUID } from "node:crypto";
|
|
|
14
14
|
import { createServer, type Server } from "node:http";
|
|
15
15
|
import type { AddressInfo } from "node:net";
|
|
16
16
|
import { after, before, describe, it } from "node:test";
|
|
17
|
+
import { AccountService } from "@remit/domain-enums";
|
|
17
18
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
18
19
|
import {
|
|
19
20
|
_resetForTest,
|
|
@@ -51,6 +52,8 @@ const startTokenEndpoint = async (): Promise<string> => {
|
|
|
51
52
|
access_token: "access-token",
|
|
52
53
|
refresh_token: "refresh-token",
|
|
53
54
|
id_token: jwt({ preferred_username: "matthijs@example.com" }),
|
|
55
|
+
scope:
|
|
56
|
+
"https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
|
|
54
57
|
expires_in: 3600,
|
|
55
58
|
}),
|
|
56
59
|
);
|
|
@@ -117,7 +120,12 @@ describe("the Microsoft OAuth callback", () => {
|
|
|
117
120
|
it("sends the consenting browser on to the connected account", async () => {
|
|
118
121
|
const accountConfigId = randomUUID();
|
|
119
122
|
const state = await signState(
|
|
120
|
-
{
|
|
123
|
+
{
|
|
124
|
+
accountConfigId,
|
|
125
|
+
nonce: "nonce",
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
services: [AccountService.Mail],
|
|
128
|
+
},
|
|
121
129
|
CLIENT_SECRET,
|
|
122
130
|
);
|
|
123
131
|
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createServer, type Server } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { after, before, beforeEach, describe, it } from "node:test";
|
|
6
|
+
import type {
|
|
7
|
+
AccountResponse,
|
|
8
|
+
AccountService as AccountServiceName,
|
|
9
|
+
} from "@remit/api-openapi-types";
|
|
10
|
+
import { AccountService } from "@remit/domain-enums";
|
|
11
|
+
import { MICROSOFT_SERVICE_SCOPES } from "@remit/mail-oauth-service";
|
|
12
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
13
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
14
|
+
import {
|
|
15
|
+
_resetForTest,
|
|
16
|
+
type RemitClient,
|
|
17
|
+
setClient,
|
|
18
|
+
} from "../service/data-client.js";
|
|
19
|
+
import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
|
|
20
|
+
|
|
21
|
+
const CLIENT_SECRET = "consent-test-client-secret";
|
|
22
|
+
const WEB_ORIGIN = "https://mail.example.com";
|
|
23
|
+
const GRAPH = "https://graph.microsoft.com/";
|
|
24
|
+
const MAIL_SCOPES = [...MICROSOFT_SERVICE_SCOPES[AccountService.Mail]];
|
|
25
|
+
const CALENDAR_SCOPES = [...MICROSOFT_SERVICE_SCOPES[AccountService.Calendar]];
|
|
26
|
+
|
|
27
|
+
let handler: (
|
|
28
|
+
event: APIGatewayProxyEvent,
|
|
29
|
+
context: unknown,
|
|
30
|
+
) => Promise<APIGatewayProxyResult>;
|
|
31
|
+
let client: RemitClient;
|
|
32
|
+
let cleanup: () => void;
|
|
33
|
+
let tokenEndpoint: Server;
|
|
34
|
+
let withheld: Set<string>;
|
|
35
|
+
let redemptions: string[][];
|
|
36
|
+
|
|
37
|
+
const jwt = (claims: Record<string, unknown>): string =>
|
|
38
|
+
[
|
|
39
|
+
Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"),
|
|
40
|
+
Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"),
|
|
41
|
+
"signature-not-checked-here",
|
|
42
|
+
].join(".");
|
|
43
|
+
|
|
44
|
+
const readBody = async (request: AsyncIterable<Buffer>): Promise<string> => {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
47
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const startTokenEndpoint = async (): Promise<string> => {
|
|
51
|
+
tokenEndpoint = createServer(async (request, response) => {
|
|
52
|
+
const form = new URLSearchParams(await readBody(request));
|
|
53
|
+
const requested = (form.get("scope") ?? "").split(" ").filter(Boolean);
|
|
54
|
+
redemptions.push(requested);
|
|
55
|
+
const granted = requested.filter((scope) => !withheld.has(scope));
|
|
56
|
+
const resourceScopes = granted.filter((scope) => scope.includes("://"));
|
|
57
|
+
if (resourceScopes.length === 0) {
|
|
58
|
+
response.writeHead(400, { "Content-Type": "application/json" });
|
|
59
|
+
response.end(
|
|
60
|
+
JSON.stringify({ error: "invalid_grant", error_codes: [65001] }),
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
65
|
+
response.end(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
access_token: "access-token",
|
|
68
|
+
refresh_token: `refresh-token-${redemptions.length}`,
|
|
69
|
+
id_token: jwt({ preferred_username: "person@example.com" }),
|
|
70
|
+
expires_in: 3600,
|
|
71
|
+
scope: granted
|
|
72
|
+
.map((scope) =>
|
|
73
|
+
scope.startsWith(GRAPH) ? scope.slice(GRAPH.length) : scope,
|
|
74
|
+
)
|
|
75
|
+
.join(" "),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
await new Promise<void>((resolve) => tokenEndpoint.listen(0, resolve));
|
|
80
|
+
const { port } = tokenEndpoint.address() as AddressInfo;
|
|
81
|
+
return `http://127.0.0.1:${port}/token`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const headerOf = (
|
|
85
|
+
response: APIGatewayProxyResult,
|
|
86
|
+
name: string,
|
|
87
|
+
): string | undefined => {
|
|
88
|
+
for (const [key, value] of Object.entries(response.headers ?? {})) {
|
|
89
|
+
if (key.toLowerCase() === name) return String(value);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const request = (
|
|
95
|
+
sub: string,
|
|
96
|
+
httpMethod: string,
|
|
97
|
+
path: string,
|
|
98
|
+
body: unknown,
|
|
99
|
+
queryStringParameters: Record<string, string> | null = null,
|
|
100
|
+
): Promise<APIGatewayProxyResult> =>
|
|
101
|
+
handler(
|
|
102
|
+
{
|
|
103
|
+
httpMethod,
|
|
104
|
+
path,
|
|
105
|
+
headers: { "content-type": "application/json" },
|
|
106
|
+
queryStringParameters,
|
|
107
|
+
body: body === null ? null : JSON.stringify(body),
|
|
108
|
+
requestContext: { authorizer: { claims: { sub } } },
|
|
109
|
+
} as unknown as APIGatewayProxyEvent,
|
|
110
|
+
{ awsRequestId: `req-${randomUUID()}`, functionName: "test" },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const startConsent = async (
|
|
114
|
+
sub: string,
|
|
115
|
+
services: AccountServiceName[] | undefined,
|
|
116
|
+
): Promise<URL> => {
|
|
117
|
+
const response = await request(
|
|
118
|
+
sub,
|
|
119
|
+
"POST",
|
|
120
|
+
"/accounts/oauth/microsoft/start",
|
|
121
|
+
{
|
|
122
|
+
email: "person@example.com",
|
|
123
|
+
services,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
assert.equal(response.statusCode, 200, response.body);
|
|
127
|
+
return new URL(
|
|
128
|
+
(JSON.parse(response.body) as { authorizationUrl: string })
|
|
129
|
+
.authorizationUrl,
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const consent = async (
|
|
134
|
+
sub: string,
|
|
135
|
+
services: AccountServiceName[] | undefined,
|
|
136
|
+
): Promise<string> => {
|
|
137
|
+
const authorizationUrl = await startConsent(sub, services);
|
|
138
|
+
const state = authorizationUrl.searchParams.get("state");
|
|
139
|
+
assert.ok(state, "the authorization URL carries the signed state");
|
|
140
|
+
const response = await request(
|
|
141
|
+
sub,
|
|
142
|
+
"GET",
|
|
143
|
+
"/accounts/oauth/microsoft/callback",
|
|
144
|
+
null,
|
|
145
|
+
{ code: "auth-code", state },
|
|
146
|
+
);
|
|
147
|
+
assert.equal(response.statusCode, 302, response.body);
|
|
148
|
+
const location = headerOf(response, "location");
|
|
149
|
+
assert.ok(location, "the callback answers with a Location");
|
|
150
|
+
return location;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onlyAccount = async (sub: string) => {
|
|
154
|
+
const accounts = await client.account.listAllByAccountConfig(
|
|
155
|
+
deriveAccountConfigId(sub),
|
|
156
|
+
);
|
|
157
|
+
assert.equal(accounts.length, 1, "the consent wrote exactly one account");
|
|
158
|
+
return accounts[0];
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const patchServices = (
|
|
162
|
+
sub: string,
|
|
163
|
+
accountId: string,
|
|
164
|
+
syncedServices: AccountServiceName[],
|
|
165
|
+
): Promise<APIGatewayProxyResult> =>
|
|
166
|
+
request(sub, "PATCH", `/accounts/${accountId}`, { syncedServices });
|
|
167
|
+
|
|
168
|
+
before(async () => {
|
|
169
|
+
_resetForTest();
|
|
170
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
171
|
+
process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
|
|
172
|
+
process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}/accounts/oauth/microsoft/callback`;
|
|
173
|
+
process.env.MSOAUTH_CLIENT_ID = "consent-test-client-id";
|
|
174
|
+
process.env.MSOAUTH_CLIENT_SECRET = CLIENT_SECRET;
|
|
175
|
+
process.env.MSOAUTH_TOKEN_ENDPOINT = await startTokenEndpoint();
|
|
176
|
+
process.env.FAKE_KMS_DATAKEY = "0".repeat(64);
|
|
177
|
+
process.env.AWS_REGION = "eu-west-1";
|
|
178
|
+
process.env.AWS_ACCESS_KEY_ID = "test";
|
|
179
|
+
process.env.AWS_SECRET_ACCESS_KEY = "test";
|
|
180
|
+
({ client, cleanup } = await createCalendarSqliteClient());
|
|
181
|
+
setClient(client);
|
|
182
|
+
({ handler } = (await import("../index.js")) as unknown as {
|
|
183
|
+
handler: typeof handler;
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
withheld = new Set();
|
|
189
|
+
redemptions = [];
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
after(async () => {
|
|
193
|
+
_resetForTest();
|
|
194
|
+
cleanup();
|
|
195
|
+
await new Promise<void>((resolve) => tokenEndpoint.close(() => resolve()));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("the Microsoft consent for the services an account syncs", () => {
|
|
199
|
+
it("asks for the calendar scope only when the start names Calendar", async () => {
|
|
200
|
+
const sub = randomUUID();
|
|
201
|
+
|
|
202
|
+
const mailOnly = (
|
|
203
|
+
await startConsent(sub, [AccountService.Mail])
|
|
204
|
+
).searchParams
|
|
205
|
+
.get("scope")
|
|
206
|
+
?.split(" ");
|
|
207
|
+
const calendarOnly = (
|
|
208
|
+
await startConsent(sub, [AccountService.Calendar])
|
|
209
|
+
).searchParams
|
|
210
|
+
.get("scope")
|
|
211
|
+
?.split(" ");
|
|
212
|
+
|
|
213
|
+
assert.ok(mailOnly);
|
|
214
|
+
assert.ok(calendarOnly);
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
mailOnly.filter((scope) => scope.includes("://")),
|
|
217
|
+
MAIL_SCOPES,
|
|
218
|
+
);
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
calendarOnly.filter((scope) => scope.includes("://")),
|
|
221
|
+
CALENDAR_SCOPES,
|
|
222
|
+
);
|
|
223
|
+
assert.ok(calendarOnly.includes("offline_access"));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("creates a calendar-only account holding the calendar grant", async () => {
|
|
227
|
+
const sub = randomUUID();
|
|
228
|
+
|
|
229
|
+
const location = await consent(sub, [AccountService.Calendar]);
|
|
230
|
+
|
|
231
|
+
const account = await onlyAccount(sub);
|
|
232
|
+
assert.equal(
|
|
233
|
+
location,
|
|
234
|
+
`${WEB_ORIGIN}/settings/accounts?connected=${encodeURIComponent(account.accountId)}`,
|
|
235
|
+
);
|
|
236
|
+
assert.deepEqual(account.syncedServices, [AccountService.Calendar]);
|
|
237
|
+
assert.deepEqual(account.grantedScopes, CALENDAR_SCOPES);
|
|
238
|
+
assert.deepEqual(redemptions, [
|
|
239
|
+
[...CALENDAR_SCOPES, "offline_access", "openid", "email"],
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("refuses a consent that comes back without the calendar scope and writes nothing", async () => {
|
|
244
|
+
const sub = randomUUID();
|
|
245
|
+
withheld = new Set(CALENDAR_SCOPES);
|
|
246
|
+
|
|
247
|
+
const location = await consent(sub, [
|
|
248
|
+
AccountService.Mail,
|
|
249
|
+
AccountService.Calendar,
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
assert.equal(
|
|
253
|
+
location,
|
|
254
|
+
`${WEB_ORIGIN}/settings/accounts?oauthError=scope_not_granted`,
|
|
255
|
+
);
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
await client.account.listAllByAccountConfig(deriveAccountConfigId(sub)),
|
|
258
|
+
[],
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("keeps calendar when a mail and calendar account reconnects naming only its email", async () => {
|
|
263
|
+
const sub = randomUUID();
|
|
264
|
+
await consent(sub, [AccountService.Mail, AccountService.Calendar]);
|
|
265
|
+
const connected = await onlyAccount(sub);
|
|
266
|
+
|
|
267
|
+
redemptions = [];
|
|
268
|
+
const location = await consent(sub, undefined);
|
|
269
|
+
|
|
270
|
+
const reconnected = await onlyAccount(sub);
|
|
271
|
+
assert.equal(
|
|
272
|
+
location,
|
|
273
|
+
`${WEB_ORIGIN}/settings/accounts?connected=${encodeURIComponent(connected.accountId)}`,
|
|
274
|
+
);
|
|
275
|
+
assert.deepEqual(reconnected.syncedServices, [
|
|
276
|
+
AccountService.Mail,
|
|
277
|
+
AccountService.Calendar,
|
|
278
|
+
]);
|
|
279
|
+
assert.deepEqual(reconnected.grantedScopes, [
|
|
280
|
+
...MAIL_SCOPES,
|
|
281
|
+
...CALENDAR_SCOPES,
|
|
282
|
+
]);
|
|
283
|
+
assert.deepEqual(
|
|
284
|
+
redemptions.map((scopes) => scopes.filter((s) => s.includes("://"))),
|
|
285
|
+
[MAIL_SCOPES, CALENDAR_SCOPES],
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("adds calendar to a mail account through a second consent", async () => {
|
|
290
|
+
const sub = randomUUID();
|
|
291
|
+
await consent(sub, [AccountService.Mail]);
|
|
292
|
+
const mailAccount = await onlyAccount(sub);
|
|
293
|
+
assert.deepEqual(mailAccount.grantedScopes, MAIL_SCOPES);
|
|
294
|
+
|
|
295
|
+
const refused = await patchServices(sub, mailAccount.accountId, [
|
|
296
|
+
AccountService.Mail,
|
|
297
|
+
AccountService.Calendar,
|
|
298
|
+
]);
|
|
299
|
+
assert.equal(refused.statusCode, 400, refused.body);
|
|
300
|
+
const { message } = JSON.parse(refused.body) as { message: string };
|
|
301
|
+
assert.match(message, /granted scopes lack Calendar/);
|
|
302
|
+
assert.match(message, /POST \/accounts\/oauth\/microsoft\/start/);
|
|
303
|
+
|
|
304
|
+
redemptions = [];
|
|
305
|
+
await consent(sub, [AccountService.Mail, AccountService.Calendar]);
|
|
306
|
+
|
|
307
|
+
const reconnected = await onlyAccount(sub);
|
|
308
|
+
assert.equal(reconnected.accountId, mailAccount.accountId);
|
|
309
|
+
assert.deepEqual(reconnected.syncedServices, [
|
|
310
|
+
AccountService.Mail,
|
|
311
|
+
AccountService.Calendar,
|
|
312
|
+
]);
|
|
313
|
+
assert.deepEqual(reconnected.grantedScopes, [
|
|
314
|
+
...MAIL_SCOPES,
|
|
315
|
+
...CALENDAR_SCOPES,
|
|
316
|
+
]);
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
redemptions.map((scopes) => scopes.filter((s) => s.includes("://"))),
|
|
319
|
+
[MAIL_SCOPES, CALENDAR_SCOPES],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const mailAgain = await patchServices(sub, mailAccount.accountId, [
|
|
323
|
+
AccountService.Mail,
|
|
324
|
+
]);
|
|
325
|
+
assert.equal(mailAgain.statusCode, 200, mailAgain.body);
|
|
326
|
+
const both = await patchServices(sub, mailAccount.accountId, [
|
|
327
|
+
AccountService.Calendar,
|
|
328
|
+
AccountService.Mail,
|
|
329
|
+
]);
|
|
330
|
+
assert.equal(both.statusCode, 200, both.body);
|
|
331
|
+
assert.deepEqual(
|
|
332
|
+
(JSON.parse(both.body) as AccountResponse).syncedServices,
|
|
333
|
+
[AccountService.Mail, AccountService.Calendar],
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -12,6 +12,7 @@ import { readFileSync } from "node:fs";
|
|
|
12
12
|
import { dirname, resolve } from "node:path";
|
|
13
13
|
import { describe, it } from "node:test";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { AccountService } from "@remit/domain-enums";
|
|
15
16
|
import {
|
|
16
17
|
getWebOrigin,
|
|
17
18
|
type OAuthState,
|
|
@@ -41,6 +42,7 @@ describe("HMAC state signing", () => {
|
|
|
41
42
|
accountConfigId: "acc-config-1",
|
|
42
43
|
nonce: "deadbeef",
|
|
43
44
|
timestamp: Date.now(),
|
|
45
|
+
services: [AccountService.Mail],
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
const state = await signState(payload, SECRET);
|
|
@@ -55,6 +57,7 @@ describe("HMAC state signing", () => {
|
|
|
55
57
|
accountConfigId: "acc-config-1",
|
|
56
58
|
nonce: "deadbeef",
|
|
57
59
|
timestamp: Date.now(),
|
|
60
|
+
services: [AccountService.Mail],
|
|
58
61
|
};
|
|
59
62
|
|
|
60
63
|
const state = await signState(payload, SECRET);
|
|
@@ -80,6 +83,7 @@ describe("HMAC state signing", () => {
|
|
|
80
83
|
accountConfigId: "acc-config-1",
|
|
81
84
|
nonce: "deadbeef",
|
|
82
85
|
timestamp: Date.now(),
|
|
86
|
+
services: [AccountService.Mail],
|
|
83
87
|
};
|
|
84
88
|
|
|
85
89
|
const state = await signState(payload, SECRET);
|
|
@@ -95,6 +99,7 @@ describe("HMAC state signing", () => {
|
|
|
95
99
|
accountConfigId: "acc-config-1",
|
|
96
100
|
nonce: "deadbeef",
|
|
97
101
|
timestamp: Date.now(),
|
|
102
|
+
services: [AccountService.Mail],
|
|
98
103
|
};
|
|
99
104
|
|
|
100
105
|
const state = await signState(payload, SECRET);
|
|
@@ -111,11 +116,61 @@ describe("HMAC state signing", () => {
|
|
|
111
116
|
);
|
|
112
117
|
});
|
|
113
118
|
|
|
119
|
+
it("reads a state signed before services existed as mail only", async () => {
|
|
120
|
+
const state = await signState(
|
|
121
|
+
{
|
|
122
|
+
accountConfigId: "acc-config-1",
|
|
123
|
+
nonce: "deadbeef",
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
} as OAuthState,
|
|
126
|
+
SECRET,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
assert.deepEqual((await verifyState(state, SECRET)).services, [
|
|
130
|
+
AccountService.Mail,
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects a state that names no requested services", async () => {
|
|
135
|
+
const state = await signState(
|
|
136
|
+
{
|
|
137
|
+
accountConfigId: "acc-config-1",
|
|
138
|
+
nonce: "deadbeef",
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
services: [],
|
|
141
|
+
},
|
|
142
|
+
SECRET,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
() => verifyState(state, SECRET),
|
|
147
|
+
/State names no requested services/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("carries the requested services through the signed state", async () => {
|
|
152
|
+
const state = await signState(
|
|
153
|
+
{
|
|
154
|
+
accountConfigId: "acc-config-1",
|
|
155
|
+
nonce: "deadbeef",
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
services: [AccountService.Mail, AccountService.Calendar],
|
|
158
|
+
},
|
|
159
|
+
SECRET,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
assert.deepEqual((await verifyState(state, SECRET)).services, [
|
|
163
|
+
AccountService.Mail,
|
|
164
|
+
AccountService.Calendar,
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
114
168
|
it("rejects an expired state (timestamp > 10 minutes ago)", async () => {
|
|
115
169
|
const expired: OAuthState = {
|
|
116
170
|
accountConfigId: "acc-config-1",
|
|
117
171
|
nonce: "deadbeef",
|
|
118
172
|
timestamp: Date.now() - STATE_TTL_MS - 1000, // 1 second past expiry
|
|
173
|
+
services: [AccountService.Mail],
|
|
119
174
|
};
|
|
120
175
|
|
|
121
176
|
const state = await signState(expired, SECRET);
|
|
@@ -128,6 +183,7 @@ describe("HMAC state signing", () => {
|
|
|
128
183
|
accountConfigId: "acc-config-1",
|
|
129
184
|
nonce: "deadbeef",
|
|
130
185
|
timestamp: Date.now() - STATE_TTL_MS + 2000, // 2 seconds before expiry
|
|
186
|
+
services: [AccountService.Mail],
|
|
131
187
|
};
|
|
132
188
|
|
|
133
189
|
const state = await signState(almostExpired, SECRET);
|
|
@@ -4,11 +4,23 @@ import {
|
|
|
4
4
|
GetSecretValueCommand,
|
|
5
5
|
SecretsManagerClient,
|
|
6
6
|
} from "@aws-sdk/client-secrets-manager";
|
|
7
|
-
import {
|
|
7
|
+
import type {
|
|
8
|
+
AccountService as AccountServiceName,
|
|
9
|
+
MicrosoftOAuthStartRequest,
|
|
10
|
+
} from "@remit/api-openapi-types";
|
|
11
|
+
import {
|
|
12
|
+
AccountAuthType,
|
|
13
|
+
AccountService,
|
|
14
|
+
ConnectionState,
|
|
15
|
+
} from "@remit/domain-enums";
|
|
8
16
|
import { logger } from "@remit/logger-lambda";
|
|
9
17
|
import {
|
|
10
18
|
createMailOAuthService,
|
|
19
|
+
type MailOAuthService,
|
|
11
20
|
microsoftProviderConfig,
|
|
21
|
+
microsoftServicesGranted,
|
|
22
|
+
orderServices,
|
|
23
|
+
RefreshTokenError,
|
|
12
24
|
} from "@remit/mail-oauth-service";
|
|
13
25
|
import { serializeEncryptedPayload } from "@remit/secrets-service";
|
|
14
26
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
@@ -31,8 +43,19 @@ export interface OAuthState {
|
|
|
31
43
|
accountConfigId: string;
|
|
32
44
|
nonce: string;
|
|
33
45
|
timestamp: number;
|
|
46
|
+
services: AccountServiceName[];
|
|
34
47
|
}
|
|
35
48
|
|
|
49
|
+
const ACCOUNT_SERVICES: readonly string[] = Object.values(AccountService);
|
|
50
|
+
|
|
51
|
+
const isServiceList = (value: unknown): value is AccountServiceName[] =>
|
|
52
|
+
Array.isArray(value) &&
|
|
53
|
+
value.length > 0 &&
|
|
54
|
+
value.every(
|
|
55
|
+
(service) =>
|
|
56
|
+
typeof service === "string" && ACCOUNT_SERVICES.includes(service),
|
|
57
|
+
);
|
|
58
|
+
|
|
36
59
|
export const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
37
60
|
|
|
38
61
|
/**
|
|
@@ -94,6 +117,14 @@ export async function verifyState(
|
|
|
94
117
|
throw new Error("State has expired");
|
|
95
118
|
}
|
|
96
119
|
|
|
120
|
+
if (payload.services === undefined) {
|
|
121
|
+
return { ...payload, services: [AccountService.Mail] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isServiceList(payload.services)) {
|
|
125
|
+
throw new Error("State names no requested services");
|
|
126
|
+
}
|
|
127
|
+
|
|
97
128
|
return payload;
|
|
98
129
|
}
|
|
99
130
|
|
|
@@ -203,6 +234,45 @@ export function getWebOrigin(): string {
|
|
|
203
234
|
return httpsOrigin ?? origins[0] ?? "https://localhost:3000";
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
type ScopeRedemption =
|
|
238
|
+
| { kind: "granted"; refreshToken: string; grantedScopes: string[] }
|
|
239
|
+
| { kind: "refused"; reason: "exchange_failed" | "scope_not_granted" };
|
|
240
|
+
|
|
241
|
+
const redeemEachService = async (
|
|
242
|
+
serviceFor: (service: AccountServiceName) => MailOAuthService,
|
|
243
|
+
initialToken: string,
|
|
244
|
+
services: readonly AccountServiceName[],
|
|
245
|
+
accountConfigId: string,
|
|
246
|
+
): Promise<ScopeRedemption> => {
|
|
247
|
+
let token = initialToken;
|
|
248
|
+
const grantedScopes: string[] = [];
|
|
249
|
+
for (const service of services) {
|
|
250
|
+
const redeemed = await serviceFor(service)
|
|
251
|
+
.refresh(token)
|
|
252
|
+
.catch((err: unknown) => {
|
|
253
|
+
logger.warn(
|
|
254
|
+
{
|
|
255
|
+
alert: "oauth_callback_failed",
|
|
256
|
+
reason: "scope_redemption_failed",
|
|
257
|
+
accountConfigId,
|
|
258
|
+
service,
|
|
259
|
+
error: inspect(err),
|
|
260
|
+
},
|
|
261
|
+
"MS OAuth callback: the consent did not cover a requested service",
|
|
262
|
+
);
|
|
263
|
+
return err instanceof RefreshTokenError &&
|
|
264
|
+
err.error.kind === "transient"
|
|
265
|
+
? ("exchange_failed" as const)
|
|
266
|
+
: ("scope_not_granted" as const);
|
|
267
|
+
});
|
|
268
|
+
if (typeof redeemed === "string")
|
|
269
|
+
return { kind: "refused", reason: redeemed };
|
|
270
|
+
grantedScopes.push(...redeemed.grantedScopes);
|
|
271
|
+
token = redeemed.refreshToken ?? token;
|
|
272
|
+
}
|
|
273
|
+
return { kind: "granted", refreshToken: token, grantedScopes };
|
|
274
|
+
};
|
|
275
|
+
|
|
206
276
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
207
277
|
|
|
208
278
|
export const MicrosoftOAuthOperations: Record<
|
|
@@ -216,8 +286,9 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
216
286
|
const event = args[0] as APIGatewayProxyEvent;
|
|
217
287
|
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
218
288
|
|
|
219
|
-
const input = JSON.parse(event.body ?? "{}") as
|
|
289
|
+
const input = JSON.parse(event.body ?? "{}") as MicrosoftOAuthStartRequest;
|
|
220
290
|
const email = typeof input.email === "string" ? input.email : undefined;
|
|
291
|
+
const services = orderServices(input.services ?? [AccountService.Mail]);
|
|
221
292
|
|
|
222
293
|
const config = getMsOAuthConfig();
|
|
223
294
|
const credentials = await getMsOAuthCredentials();
|
|
@@ -226,6 +297,7 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
226
297
|
microsoftProviderConfig({
|
|
227
298
|
clientId: credentials.clientId,
|
|
228
299
|
clientSecret: credentials.clientSecret,
|
|
300
|
+
services,
|
|
229
301
|
overrides: config.tokenEndpoint
|
|
230
302
|
? { tokenEndpoint: config.tokenEndpoint }
|
|
231
303
|
: undefined,
|
|
@@ -237,6 +309,7 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
237
309
|
accountConfigId,
|
|
238
310
|
nonce,
|
|
239
311
|
timestamp: Date.now(),
|
|
312
|
+
services,
|
|
240
313
|
};
|
|
241
314
|
|
|
242
315
|
const state = await signState(statePayload, credentials.clientSecret);
|
|
@@ -248,7 +321,10 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
248
321
|
});
|
|
249
322
|
|
|
250
323
|
// biome-ignore lint/plugin/no-logger-info: OAuth initiation is an audit-grade signal
|
|
251
|
-
logger.info(
|
|
324
|
+
logger.info(
|
|
325
|
+
{ accountConfigId, services },
|
|
326
|
+
"Microsoft OAuth start initiated",
|
|
327
|
+
);
|
|
252
328
|
|
|
253
329
|
return { authorizationUrl };
|
|
254
330
|
},
|
|
@@ -329,20 +405,23 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
329
405
|
);
|
|
330
406
|
|
|
331
407
|
const { accountConfigId } = statePayload;
|
|
408
|
+
const [firstService] = orderServices(statePayload.services);
|
|
332
409
|
|
|
333
410
|
const config = getMsOAuthConfig();
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
:
|
|
341
|
-
|
|
342
|
-
|
|
411
|
+
const serviceFor = (service: AccountServiceName): MailOAuthService =>
|
|
412
|
+
createMailOAuthService(
|
|
413
|
+
microsoftProviderConfig({
|
|
414
|
+
clientId: credentials.clientId,
|
|
415
|
+
clientSecret: credentials.clientSecret,
|
|
416
|
+
services: [service],
|
|
417
|
+
overrides: config.tokenEndpoint
|
|
418
|
+
? { tokenEndpoint: config.tokenEndpoint }
|
|
419
|
+
: undefined,
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
343
422
|
|
|
344
423
|
// Exchange the authorization code for tokens
|
|
345
|
-
const tokenSet = await
|
|
424
|
+
const tokenSet = await serviceFor(firstService)
|
|
346
425
|
.exchangeCode(code, config.redirectUri)
|
|
347
426
|
.catch((err: unknown) => {
|
|
348
427
|
logger.error(
|
|
@@ -410,16 +489,6 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
410
489
|
|
|
411
490
|
const { account, accountConfig, secrets } = await getClient();
|
|
412
491
|
|
|
413
|
-
// Ensure the account config row exists for this user
|
|
414
|
-
await ensureAccountConfig(accountConfig, accountConfigId);
|
|
415
|
-
|
|
416
|
-
// Encrypt the refresh token
|
|
417
|
-
const tokenPayload = await secrets.encrypt(tokenSet.refreshToken);
|
|
418
|
-
const oauthRefreshTokenHash = JSON.stringify(
|
|
419
|
-
serializeEncryptedPayload(tokenPayload),
|
|
420
|
-
);
|
|
421
|
-
const oauthTokenUpdatedAt = Date.now();
|
|
422
|
-
|
|
423
492
|
// Reconnect when an active OAuth account already onboards this mailbox.
|
|
424
493
|
// Same natural key as the IMAP create guard (#635); the OAuth flow returns
|
|
425
494
|
// the existing account (token refresh) rather than rejecting, because a
|
|
@@ -432,6 +501,55 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
432
501
|
username: email,
|
|
433
502
|
});
|
|
434
503
|
|
|
504
|
+
const services = orderServices([
|
|
505
|
+
...statePayload.services,
|
|
506
|
+
...(existing?.syncedServices ?? []),
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
const redemption = await redeemEachService(
|
|
510
|
+
serviceFor,
|
|
511
|
+
tokenSet.refreshToken,
|
|
512
|
+
services.filter((service) => service !== firstService),
|
|
513
|
+
accountConfigId,
|
|
514
|
+
);
|
|
515
|
+
if (redemption.kind === "refused")
|
|
516
|
+
return redirect(
|
|
517
|
+
`${webOrigin}/settings/accounts?oauthError=${redemption.reason}`,
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const grantedScopes = [
|
|
521
|
+
...new Set([...tokenSet.grantedScopes, ...redemption.grantedScopes]),
|
|
522
|
+
];
|
|
523
|
+
const grantedServices = microsoftServicesGranted(grantedScopes);
|
|
524
|
+
const missingServices = services.filter(
|
|
525
|
+
(service) => !grantedServices.includes(service),
|
|
526
|
+
);
|
|
527
|
+
if (missingServices.length > 0) {
|
|
528
|
+
logger.warn(
|
|
529
|
+
{
|
|
530
|
+
alert: "oauth_callback_failed",
|
|
531
|
+
reason: "scope_not_granted",
|
|
532
|
+
accountConfigId,
|
|
533
|
+
missingServices,
|
|
534
|
+
grantedScopes,
|
|
535
|
+
},
|
|
536
|
+
"MS OAuth callback: granted scopes lack a requested service",
|
|
537
|
+
);
|
|
538
|
+
return redirect(
|
|
539
|
+
`${webOrigin}/settings/accounts?oauthError=scope_not_granted`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Ensure the account config row exists for this user
|
|
544
|
+
await ensureAccountConfig(accountConfig, accountConfigId);
|
|
545
|
+
|
|
546
|
+
// Encrypt the refresh token
|
|
547
|
+
const tokenPayload = await secrets.encrypt(redemption.refreshToken);
|
|
548
|
+
const oauthRefreshTokenHash = JSON.stringify(
|
|
549
|
+
serializeEncryptedPayload(tokenPayload),
|
|
550
|
+
);
|
|
551
|
+
const oauthTokenUpdatedAt = Date.now();
|
|
552
|
+
|
|
435
553
|
let accountId: string;
|
|
436
554
|
|
|
437
555
|
if (existing) {
|
|
@@ -441,6 +559,8 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
441
559
|
{
|
|
442
560
|
oauthRefreshTokenHash,
|
|
443
561
|
oauthTokenUpdatedAt,
|
|
562
|
+
syncedServices: services,
|
|
563
|
+
grantedScopes,
|
|
444
564
|
connectionState: ConnectionState.NotAuthenticated,
|
|
445
565
|
},
|
|
446
566
|
["lastError"] as never,
|
|
@@ -458,6 +578,8 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
458
578
|
email,
|
|
459
579
|
username: email,
|
|
460
580
|
authType: AccountAuthType.OauthMicrosoft,
|
|
581
|
+
syncedServices: services,
|
|
582
|
+
grantedScopes,
|
|
461
583
|
oauthRefreshTokenHash,
|
|
462
584
|
oauthTokenUpdatedAt,
|
|
463
585
|
imapHost: OUTLOOK_IMAP_HOST,
|
|
@@ -480,7 +602,9 @@ export const MicrosoftOAuthOperations: Record<
|
|
|
480
602
|
);
|
|
481
603
|
}
|
|
482
604
|
|
|
483
|
-
|
|
605
|
+
if (services.includes(AccountService.Mail)) {
|
|
606
|
+
await triggerAccountSyncSafe(accountId);
|
|
607
|
+
}
|
|
484
608
|
|
|
485
609
|
return redirect(
|
|
486
610
|
`${webOrigin}/settings/accounts?connected=${encodeURIComponent(accountId)}`,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AccountResponse,
|
|
5
|
+
AccountService as AccountServiceName,
|
|
6
|
+
} from "@remit/api-openapi-types";
|
|
4
7
|
import type { AccountItem } from "@remit/data-ports";
|
|
5
8
|
import {
|
|
6
9
|
AccountAuthType,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
MailboxRepo,
|
|
16
19
|
} from "@remit/drizzle-service";
|
|
17
20
|
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
21
|
+
import { MICROSOFT_SERVICE_SCOPES } from "@remit/mail-oauth-service";
|
|
18
22
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
19
23
|
import type { Context } from "openapi-backend";
|
|
20
24
|
import { deriveAccountConfigId } from "../auth.js";
|
|
@@ -65,12 +69,21 @@ const bodyOf = <T>(response: APIGatewayProxyResult): T =>
|
|
|
65
69
|
let accounts: AccountRepo;
|
|
66
70
|
let close: () => void;
|
|
67
71
|
|
|
68
|
-
const seedAccount = (
|
|
72
|
+
const seedAccount = (
|
|
73
|
+
authType: AccountItem["authType"],
|
|
74
|
+
grantedServices: AccountServiceName[] = authType ===
|
|
75
|
+
AccountAuthType.OauthMicrosoft
|
|
76
|
+
? [AccountService.Mail]
|
|
77
|
+
: [],
|
|
78
|
+
) =>
|
|
69
79
|
accounts.create({
|
|
70
80
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
71
81
|
username: `${authType}@example.com`,
|
|
72
82
|
email: `${authType}@example.com`,
|
|
73
83
|
authType,
|
|
84
|
+
grantedScopes: grantedServices.flatMap(
|
|
85
|
+
(service) => MICROSOFT_SERVICE_SCOPES[service],
|
|
86
|
+
),
|
|
74
87
|
imapHost: "imap.example.com",
|
|
75
88
|
imapPort: 993,
|
|
76
89
|
imapTls: true,
|
|
@@ -175,6 +188,22 @@ describe("the services an account syncs", () => {
|
|
|
175
188
|
]);
|
|
176
189
|
});
|
|
177
190
|
|
|
191
|
+
it("accepts calendar once the granted scopes cover it", async () => {
|
|
192
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft, [
|
|
193
|
+
AccountService.Mail,
|
|
194
|
+
AccountService.Calendar,
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
const response = await patchServices(account.accountId, [
|
|
198
|
+
AccountService.Calendar,
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
assert.equal(response.statusCode, 200);
|
|
202
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
203
|
+
AccountService.Calendar,
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
178
207
|
it("refuses calendar the Microsoft consent never covered and names the consent route", async () => {
|
|
179
208
|
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
180
209
|
|
|
@@ -183,10 +212,9 @@ describe("the services an account syncs", () => {
|
|
|
183
212
|
]);
|
|
184
213
|
|
|
185
214
|
assert.equal(response.statusCode, 400);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
215
|
+
const { message } = bodyOf<{ message: string }>(response);
|
|
216
|
+
assert.match(message, /granted scopes lack Calendar/);
|
|
217
|
+
assert.match(message, /POST \/accounts\/oauth\/microsoft\/start/);
|
|
190
218
|
assert.deepEqual(await readServices(account.accountId), [
|
|
191
219
|
AccountService.Mail,
|
|
192
220
|
]);
|
package/src/handlers/account.ts
CHANGED
|
@@ -209,7 +209,7 @@ export const AccountOperations: Record<
|
|
|
209
209
|
assertPasswordProvided(input.authType, input.password);
|
|
210
210
|
|
|
211
211
|
const syncedServices = resolveSyncedServices(
|
|
212
|
-
AccountAuthType.Password,
|
|
212
|
+
{ authType: AccountAuthType.Password, grantedScopes: [] },
|
|
213
213
|
input.syncedServices ?? [AccountService.Mail],
|
|
214
214
|
);
|
|
215
215
|
|
|
@@ -418,7 +418,7 @@ export const AccountDetailOperations: Record<
|
|
|
418
418
|
|
|
419
419
|
if (input.syncedServices !== undefined) {
|
|
420
420
|
updates.syncedServices = resolveSyncedServices(
|
|
421
|
-
existing
|
|
421
|
+
existing,
|
|
422
422
|
input.syncedServices,
|
|
423
423
|
);
|
|
424
424
|
}
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -327,6 +327,7 @@ const toMailboxResponse = (
|
|
|
327
327
|
// client cannot tell a create in flight from a rename in flight, name the
|
|
328
328
|
// target of a failed rename, or offer the right retry.
|
|
329
329
|
pendingPath: mailbox.pendingPath,
|
|
330
|
+
syncFailureReason: mailbox.syncFailureReason,
|
|
330
331
|
muted: overrides.muted,
|
|
331
332
|
displayNameOverride: overrides.displayNameOverride,
|
|
332
333
|
createdAt: mailbox.createdAt,
|