@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.126",
3
+ "version": "0.0.128",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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
- authType: string,
93
+ account: Pick<AccountItem, "authType" | "grantedScopes">,
94
94
  requested: readonly AccountServiceName[],
95
95
  ): AccountServiceName[] => {
96
- const services = Object.values(AccountService).filter((service) =>
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 uncovered = services.filter(
113
- (service) => !MICROSOFT_GRANT_SERVICES.includes(service),
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 Microsoft consent does not cover ${uncovered.join(", ")}. Grant it through POST /accounts/oauth/microsoft/start.`,
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
- { accountConfigId, nonce: "nonce", timestamp: Date.now() },
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 { AccountAuthType, ConnectionState } from "@remit/domain-enums";
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 { email?: string };
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({ accountConfigId }, "Microsoft OAuth start initiated");
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 oauthService = createMailOAuthService(
335
- microsoftProviderConfig({
336
- clientId: credentials.clientId,
337
- clientSecret: credentials.clientSecret,
338
- overrides: config.tokenEndpoint
339
- ? { tokenEndpoint: config.tokenEndpoint }
340
- : undefined,
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 oauthService
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
- await triggerAccountSyncSafe(accountId);
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 { AccountResponse } from "@remit/api-openapi-types";
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 = (authType: AccountItem["authType"]) =>
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
- assert.match(
187
- bodyOf<{ message: string }>(response).message,
188
- /POST \/accounts\/oauth\/microsoft\/start/,
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
  ]);
@@ -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.authType,
421
+ existing,
422
422
  input.syncedServices,
423
423
  );
424
424
  }
@@ -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,