@remit/backend 0.0.144 → 0.0.146

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.
Files changed (91) hide show
  1. package/dist/src/api.d.ts +0 -2
  2. package/dist/src/api.js +9 -80
  3. package/dist/src/auth.d.ts +13 -19
  4. package/dist/src/auth.js +51 -43
  5. package/dist/src/config/api-config.d.ts +20 -0
  6. package/dist/src/config/api-config.js +1 -0
  7. package/dist/src/config/from-env.d.ts +2 -0
  8. package/dist/src/config/from-env.js +65 -0
  9. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  10. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  11. package/dist/src/config/msoauth.d.ts +18 -29
  12. package/dist/src/config/msoauth.js +4 -28
  13. package/dist/src/derive/contentSignature.d.ts +3 -4
  14. package/dist/src/derive/contentSignature.js +4 -13
  15. package/dist/src/derive/contentUrl.d.ts +0 -13
  16. package/dist/src/derive/contentUrl.js +0 -19
  17. package/dist/src/handlers/account-oauth.d.ts +9 -3
  18. package/dist/src/handlers/account-oauth.js +197 -250
  19. package/dist/src/handlers/account.d.ts +13 -4
  20. package/dist/src/handlers/account.js +26 -39
  21. package/dist/src/handlers/address.d.ts +11 -3
  22. package/dist/src/handlers/address.js +11 -19
  23. package/dist/src/handlers/calendar-event.js +18 -26
  24. package/dist/src/handlers/calendar-feed.d.ts +2 -2
  25. package/dist/src/handlers/calendar-feed.js +24 -32
  26. package/dist/src/handlers/calendar-suggestion.js +18 -26
  27. package/dist/src/handlers/calendar.d.ts +1 -1
  28. package/dist/src/handlers/calendar.js +15 -22
  29. package/dist/src/handlers/config.d.ts +16 -6
  30. package/dist/src/handlers/config.js +23 -35
  31. package/dist/src/handlers/filter.d.ts +16 -3
  32. package/dist/src/handlers/filter.js +17 -32
  33. package/dist/src/handlers/folder-role.js +3 -6
  34. package/dist/src/handlers/index.d.ts +187 -2
  35. package/dist/src/handlers/index.js +6 -4
  36. package/dist/src/handlers/label.js +15 -22
  37. package/dist/src/handlers/mailbox.d.ts +20 -4
  38. package/dist/src/handlers/mailbox.js +18 -26
  39. package/dist/src/handlers/me.d.ts +10 -2
  40. package/dist/src/handlers/me.js +26 -47
  41. package/dist/src/handlers/message.d.ts +78 -6
  42. package/dist/src/handlers/message.js +34 -46
  43. package/dist/src/handlers/organize.d.ts +10 -4
  44. package/dist/src/handlers/organize.js +11 -20
  45. package/dist/src/handlers/outbox-attachment.d.ts +6 -5
  46. package/dist/src/handlers/outbox-attachment.js +11 -15
  47. package/dist/src/handlers/outbox.d.ts +25 -4
  48. package/dist/src/handlers/outbox.js +27 -35
  49. package/dist/src/handlers/sync.d.ts +19 -6
  50. package/dist/src/handlers/sync.js +13 -22
  51. package/dist/src/handlers/system-update.js +14 -27
  52. package/dist/src/handlers/thread.js +9 -14
  53. package/dist/src/handlers/unified-threads.js +3 -6
  54. package/dist/src/index.d.ts +4 -1
  55. package/dist/src/index.js +18 -2
  56. package/dist/src/jwt-auth.d.ts +9 -12
  57. package/dist/src/jwt-auth.js +19 -71
  58. package/dist/src/lambda.d.ts +13 -0
  59. package/dist/src/lambda.js +33 -0
  60. package/dist/src/request.d.ts +8 -2
  61. package/dist/src/request.js +16 -6
  62. package/dist/src/response.d.ts +20 -3
  63. package/dist/src/response.js +15 -22
  64. package/dist/src/serve.d.ts +31 -0
  65. package/dist/src/serve.js +117 -0
  66. package/dist/src/service/account-deletion.d.ts +2 -4
  67. package/dist/src/service/account-deletion.js +3 -9
  68. package/dist/src/service/compose-sqlite.d.ts +2 -1
  69. package/dist/src/service/compose-sqlite.js +5 -3
  70. package/dist/src/service/create-remit-client.d.ts +25 -11
  71. package/dist/src/service/create-remit-client.js +97 -116
  72. package/dist/src/service/data-client.js +3 -5
  73. package/dist/src/service/export-identity.d.ts +2 -1
  74. package/dist/src/service/export-identity.js +4 -6
  75. package/dist/src/service/fire-and-forget.js +1 -2
  76. package/dist/src/service/organize.d.ts +7 -10
  77. package/dist/src/service/organize.js +10 -27
  78. package/dist/src/service/queues.d.ts +12 -0
  79. package/dist/src/service/queues.js +1 -0
  80. package/dist/src/service/shared-deps.d.ts +6 -0
  81. package/dist/src/service/shared-deps.js +26 -0
  82. package/dist/src/service/trigger-sync.d.ts +11 -5
  83. package/dist/src/service/trigger-sync.js +10 -18
  84. package/dist/src/types.d.ts +24 -1
  85. package/package.json +24 -8
  86. package/dist/src/data-backend.d.ts +0 -12
  87. package/dist/src/data-backend.js +0 -12
  88. package/dist/src/request-context.d.ts +0 -9
  89. package/dist/src/request-context.js +0 -22
  90. package/dist/src/service/sqs.d.ts +0 -1
  91. package/dist/src/service/sqs.js +0 -3
@@ -1,15 +1,11 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { inspect } from "node:util";
3
- import { GetSecretValueCommand, SecretsManagerClient, } from "@aws-sdk/client-secrets-manager";
4
2
  import { AccountAuthType, AccountService, ConnectionState, } from "@remit/domain-enums";
5
3
  import { logger } from "@remit/logger";
6
4
  import { createMailOAuthService, microsoftProviderConfig, microsoftServicesGranted, orderServices, RefreshTokenError, } from "@remit/mail-oauth-service";
7
5
  import { serializeEncryptedPayload } from "@remit/secrets-service";
8
- import { getAccountConfigIdFromEvent } from "../auth.js";
9
- import { getMsOAuthConfig } from "../config/msoauth.js";
6
+ import { loadMsOAuth } from "../config/msoauth.js";
10
7
  import { safeJsonParse } from "../json.js";
11
8
  import { rawApiResponse } from "../response.js";
12
- import { getClient } from "../service/data-client.js";
13
9
  import { triggerAccountSyncSafe } from "./account.js";
14
10
  import { findActiveDuplicateMailbox } from "./account-guards.js";
15
11
  import { ensureAccountConfig } from "./ensure-account-config.js";
@@ -74,49 +70,8 @@ export async function parseJwtClaims(token) {
74
70
  return null;
75
71
  return safeJsonParse(Buffer.from(parts[1], "base64url").toString("utf8")).catch(() => null);
76
72
  }
77
- let smClient = null;
78
- function getSecretsManagerClient() {
79
- if (!smClient)
80
- smClient = new SecretsManagerClient({});
81
- return smClient;
82
- }
83
- function credentialFailure(name, message) {
84
- const error = new Error(message);
85
- error.name = name;
86
- return error;
87
- }
88
- async function getMsOAuthCredentials() {
89
- const config = getMsOAuthConfig();
90
- // Local dev: use env vars directly
91
- if (config.clientId && config.clientSecret) {
92
- return { clientId: config.clientId, clientSecret: config.clientSecret };
93
- }
94
- // Production: fetch from Secrets Manager
95
- if (!config.secretArn) {
96
- throw credentialFailure("MicrosoftOAuthNotConfigured", "No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN");
97
- }
98
- const sm = getSecretsManagerClient();
99
- const result = await sm.send(new GetSecretValueCommand({ SecretId: config.secretArn }));
100
- if (!result.SecretString) {
101
- throw credentialFailure("MicrosoftOAuthSecretEmpty", "The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN holds no SecretString");
102
- }
103
- const parsed = await safeJsonParse(result.SecretString).catch(() => null);
104
- if (!parsed) {
105
- throw credentialFailure("MicrosoftOAuthSecretUnparseable", "The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN does not hold a JSON object");
106
- }
107
- const absent = ["clientId", "clientSecret"].filter((field) => !parsed[field]);
108
- if (absent.length > 0) {
109
- throw credentialFailure("MicrosoftOAuthSecretIncomplete", `The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN is missing ${absent.join(" and ")}`);
110
- }
111
- return parsed;
112
- }
113
73
  // ─── Web origin helper ───────────────────────────────────────────────────────
114
- export function getWebOrigin() {
115
- const raw = process.env.CORS_ALLOWED_ORIGINS ?? "";
116
- const origins = raw
117
- .split(",")
118
- .map((o) => o.trim())
119
- .filter(Boolean);
74
+ export function getWebOrigin(origins) {
120
75
  // Prefer the first HTTPS origin (production frontend)
121
76
  const httpsOrigin = origins.find((o) => o.startsWith("https://"));
122
77
  return httpsOrigin ?? origins[0] ?? "https://localhost:3000";
@@ -140,7 +95,7 @@ const redeemEachService = async (serviceFor, initialToken, services, accountConf
140
95
  reason: "scope_redemption_failed",
141
96
  accountConfigId,
142
97
  service,
143
- error: inspect(err),
98
+ error: err,
144
99
  }, "MS OAuth callback: the consent did not cover a requested service");
145
100
  return err instanceof RefreshTokenError &&
146
101
  err.error.kind === "transient"
@@ -157,22 +112,207 @@ const redeemEachService = async (serviceFor, initialToken, services, accountConf
157
112
  return { kind: "granted", refreshToken: token, grantedScopes };
158
113
  };
159
114
  // ─── Handlers ────────────────────────────────────────────────────────────────
115
+ export const microsoftOAuthCallback = async (context, ctx) => {
116
+ const webOrigin = getWebOrigin(ctx.config.corsAllowedOrigins);
117
+ const qs = context.request.query;
118
+ // Marked raw so the response formatter forwards the Location header
119
+ // instead of serializing this object as a JSON body. Microsoft's browser
120
+ // redirect lands here, so a dropped Location is a blank page.
121
+ const redirect = (url) => rawApiResponse({
122
+ statusCode: 302,
123
+ headers: { Location: url },
124
+ body: "",
125
+ });
126
+ // If Microsoft returned an error, pass it through to the frontend
127
+ if (qs.error) {
128
+ const errorCode = encodeURIComponent(qs.error);
129
+ return redirect(`${webOrigin}/settings/accounts?oauthError=${errorCode}`);
130
+ }
131
+ const code = qs.code;
132
+ const state = qs.state;
133
+ if (!code || !state) {
134
+ return redirect(`${webOrigin}/settings/accounts?oauthError=missing_params`);
135
+ }
136
+ const loaded = await loadMsOAuth(ctx.config.msOAuth).catch((err) => {
137
+ logger.error({
138
+ alert: "oauth_callback_failed",
139
+ reason: "config_error",
140
+ errorName: err?.name,
141
+ errorCode: err?.Code ?? err?.code,
142
+ error: err,
143
+ }, "MS OAuth callback: failed to load OAuth config");
144
+ return null;
145
+ });
146
+ if (loaded === null)
147
+ return redirect(`${webOrigin}/settings/accounts?oauthError=config_error`);
148
+ const { config, credentials } = loaded;
149
+ // Verify the HMAC-signed state
150
+ // A bad/expired/forged state is a security-relevant signal (CSRF
151
+ // attempt, clock skew, or a stale callback) — must not be swallowed
152
+ // silently. Warn so it's observable; still redirect the user cleanly.
153
+ const statePayload = await verifyState(state, credentials.clientSecret).catch((err) => {
154
+ logger.warn({
155
+ alert: "oauth_callback_failed",
156
+ reason: "invalid_state",
157
+ errorName: err?.name,
158
+ error: err,
159
+ }, "MS OAuth callback: state verification failed");
160
+ return null;
161
+ });
162
+ if (statePayload === null)
163
+ return redirect(`${webOrigin}/settings/accounts?oauthError=invalid_state`);
164
+ const { accountConfigId } = statePayload;
165
+ const [firstService] = orderServices(statePayload.services);
166
+ const serviceFor = (service) => createMailOAuthService(microsoftProviderConfig({
167
+ clientId: credentials.clientId,
168
+ clientSecret: credentials.clientSecret,
169
+ services: [service],
170
+ overrides: config.overrides,
171
+ }));
172
+ // Exchange the authorization code for tokens
173
+ const tokenSet = await serviceFor(firstService)
174
+ .exchangeCode(code, config.redirectUri)
175
+ .catch((err) => {
176
+ logger.error({ accountConfigId, error: err }, "MS OAuth code exchange failed");
177
+ return null;
178
+ });
179
+ if (tokenSet === null)
180
+ return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
181
+ if (!tokenSet.refreshToken) {
182
+ logger.error({ accountConfigId }, "MS OAuth token exchange returned no refresh_token");
183
+ return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
184
+ }
185
+ // Extract the user's email from the ID token returned alongside the access
186
+ // token when openid+email scopes are requested.
187
+ //
188
+ // We prefer the ID token (idToken) because Outlook resource access tokens
189
+ // (IMAP.AccessAsUser.All, SMTP.Send) are opaque/non-JWT and cannot be
190
+ // decoded. The ID token is a standard OIDC token that always contains
191
+ // identity claims (preferred_username / email).
192
+ //
193
+ // Falls back to parsing the access token only in test/dev environments where
194
+ // idToken may not be present (e.g. custom token endpoints that don't issue
195
+ // OIDC tokens).
196
+ const tokenToParse = tokenSet.idToken ?? tokenSet.accessToken;
197
+ const claims = await parseJwtClaims(tokenToParse);
198
+ if (!claims) {
199
+ // A non-JWT or unparseable token. The empty-email guard below turns
200
+ // this into a user-facing missing_email redirect; log here so the parse
201
+ // failure itself is observable (e.g. an unexpected token shape).
202
+ logger.warn({
203
+ alert: "oauth_callback_failed",
204
+ reason: "jwt_parse_failed",
205
+ accountConfigId,
206
+ }, "MS OAuth callback: failed to parse identity token claims");
207
+ }
208
+ const email = claims
209
+ ? (claims.preferred_username ??
210
+ claims.email ??
211
+ claims.upn)
212
+ : undefined;
213
+ if (!email) {
214
+ logger.error({ accountConfigId }, "MS OAuth: could not extract email from token claims");
215
+ return redirect(`${webOrigin}/settings/accounts?oauthError=missing_email`);
216
+ }
217
+ const { account, accountConfig, secrets } = ctx.client;
218
+ // Reconnect when an active OAuth account already onboards this mailbox.
219
+ // Same natural key as the IMAP create guard (#635); the OAuth flow returns
220
+ // the existing account (token refresh) rather than rejecting, because a
221
+ // re-auth is the expected, idempotent path for OAuth.
222
+ const existingAccounts = (await account.listAllByAccountConfig(accountConfigId)).filter((a) => a.authType === AccountAuthType.OauthMicrosoft);
223
+ const existing = findActiveDuplicateMailbox(existingAccounts, {
224
+ imapHost: OUTLOOK_IMAP_HOST,
225
+ username: email,
226
+ });
227
+ const services = orderServices([
228
+ ...statePayload.services,
229
+ ...(existing?.syncedServices ?? []),
230
+ ]);
231
+ const redemption = await redeemEachService(serviceFor, tokenSet.refreshToken, services.filter((service) => service !== firstService), accountConfigId);
232
+ if (redemption.kind === "refused" &&
233
+ redemption.reason === "scope_not_granted")
234
+ return redirect(scopeRefusalUrl(webOrigin, email, [redemption.service]));
235
+ if (redemption.kind === "refused")
236
+ return redirect(`${webOrigin}/settings/accounts?oauthError=${redemption.reason}`);
237
+ const grantedScopes = [
238
+ ...new Set([...tokenSet.grantedScopes, ...redemption.grantedScopes]),
239
+ ];
240
+ const grantedServices = microsoftServicesGranted(grantedScopes);
241
+ const missingServices = services.filter((service) => !grantedServices.includes(service));
242
+ if (missingServices.length > 0) {
243
+ logger.warn({
244
+ alert: "oauth_callback_failed",
245
+ reason: "scope_not_granted",
246
+ accountConfigId,
247
+ missingServices,
248
+ grantedScopes,
249
+ }, "MS OAuth callback: granted scopes lack a requested service");
250
+ return redirect(scopeRefusalUrl(webOrigin, email, missingServices));
251
+ }
252
+ // Ensure the account config row exists for this user
253
+ await ensureAccountConfig(accountConfig, accountConfigId);
254
+ // Encrypt the refresh token
255
+ const tokenPayload = await secrets.encrypt(redemption.refreshToken);
256
+ const oauthRefreshTokenHash = JSON.stringify(serializeEncryptedPayload(tokenPayload));
257
+ const oauthTokenUpdatedAt = Date.now();
258
+ let accountId;
259
+ if (existing) {
260
+ // Reconnect: update the stored token and clear any reauth_required state
261
+ const updated = await account.update(existing.accountId, {
262
+ oauthRefreshTokenHash,
263
+ oauthTokenUpdatedAt,
264
+ syncedServices: services,
265
+ grantedScopes,
266
+ connectionState: ConnectionState.NotAuthenticated,
267
+ }, ["lastError"]);
268
+ accountId = updated.accountId;
269
+ // biome-ignore lint/plugin/no-logger-info: OAuth reconnect is an audit-grade signal
270
+ logger.info({ accountConfigId, accountId }, "MS OAuth reconnect: refresh token updated");
271
+ }
272
+ else {
273
+ // First connect: create a new account
274
+ const newAccount = await account.create({
275
+ accountConfigId,
276
+ email,
277
+ username: email,
278
+ authType: AccountAuthType.OauthMicrosoft,
279
+ syncedServices: services,
280
+ grantedScopes,
281
+ oauthRefreshTokenHash,
282
+ oauthTokenUpdatedAt,
283
+ imapHost: OUTLOOK_IMAP_HOST,
284
+ imapPort: 993,
285
+ imapTls: true,
286
+ imapStartTls: false,
287
+ smtpEnabled: true,
288
+ smtpHost: "smtp.office365.com",
289
+ smtpPort: 587,
290
+ smtpTls: false,
291
+ smtpStartTls: true,
292
+ isActive: true,
293
+ connectionState: ConnectionState.NotAuthenticated,
294
+ });
295
+ accountId = newAccount.accountId;
296
+ // biome-ignore lint/plugin/no-logger-info: OAuth account creation is an audit-grade signal
297
+ logger.info({ accountConfigId, accountId }, "MS OAuth: new account created");
298
+ }
299
+ if (services.includes(AccountService.Mail)) {
300
+ await triggerAccountSyncSafe(ctx.client, accountId);
301
+ }
302
+ return redirect(`${webOrigin}/settings/accounts?connected=${encodeURIComponent(accountId)}`);
303
+ };
160
304
  export const MicrosoftOAuthOperations = {
161
- MicrosoftOAuthOperations_microsoftOAuthStart: async (_context, ...args) => {
162
- const event = args[0];
163
- const accountConfigId = getAccountConfigIdFromEvent(event);
164
- const input = JSON.parse(event.body ?? "{}");
305
+ MicrosoftOAuthOperations_microsoftOAuthStart: async (context, ctx) => {
306
+ const accountConfigId = ctx.identity.accountConfigId;
307
+ const input = context.request.requestBody;
165
308
  const email = typeof input.email === "string" ? input.email : undefined;
166
309
  const services = orderServices(input.services ?? [AccountService.Mail]);
167
- const config = getMsOAuthConfig();
168
- const credentials = await getMsOAuthCredentials();
310
+ const { config, credentials } = await loadMsOAuth(ctx.config.msOAuth);
169
311
  const oauthService = createMailOAuthService(microsoftProviderConfig({
170
312
  clientId: credentials.clientId,
171
313
  clientSecret: credentials.clientSecret,
172
314
  services,
173
- overrides: config.tokenEndpoint
174
- ? { tokenEndpoint: config.tokenEndpoint }
175
- : undefined,
315
+ overrides: config.overrides,
176
316
  }));
177
317
  const nonce = randomBytes(16).toString("hex");
178
318
  const statePayload = {
@@ -191,197 +331,4 @@ export const MicrosoftOAuthOperations = {
191
331
  logger.info({ accountConfigId, services }, "Microsoft OAuth start initiated");
192
332
  return { authorizationUrl };
193
333
  },
194
- MicrosoftOAuthOperations_microsoftOAuthCallback: async (_context, ...args) => {
195
- const event = args[0];
196
- const webOrigin = getWebOrigin();
197
- const qs = event.queryStringParameters ?? {};
198
- // Marked raw so the response formatter forwards the Location header
199
- // instead of serializing this object as a JSON body. Microsoft's browser
200
- // redirect lands here, so a dropped Location is a blank page.
201
- const redirect = (url) => rawApiResponse({
202
- statusCode: 302,
203
- headers: { Location: url },
204
- body: "",
205
- });
206
- // If Microsoft returned an error, pass it through to the frontend
207
- if (qs.error) {
208
- const errorCode = encodeURIComponent(qs.error);
209
- return redirect(`${webOrigin}/settings/accounts?oauthError=${errorCode}`);
210
- }
211
- const code = qs.code;
212
- const state = qs.state;
213
- if (!code || !state) {
214
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_params`);
215
- }
216
- const credentials = await getMsOAuthCredentials().catch((err) => {
217
- logger.error({
218
- alert: "oauth_callback_failed",
219
- reason: "config_error",
220
- errorName: err?.name,
221
- errorCode: err?.Code ??
222
- err?.code,
223
- error: inspect(err),
224
- }, "MS OAuth callback: failed to load OAuth config");
225
- return null;
226
- });
227
- if (credentials === null)
228
- return redirect(`${webOrigin}/settings/accounts?oauthError=config_error`);
229
- // Verify the HMAC-signed state
230
- // A bad/expired/forged state is a security-relevant signal (CSRF
231
- // attempt, clock skew, or a stale callback) — must not be swallowed
232
- // silently. Warn so it's observable; still redirect the user cleanly.
233
- const statePayload = await verifyState(state, credentials.clientSecret).catch((err) => {
234
- logger.warn({
235
- alert: "oauth_callback_failed",
236
- reason: "invalid_state",
237
- errorName: err?.name,
238
- error: inspect(err),
239
- }, "MS OAuth callback: state verification failed");
240
- return null;
241
- });
242
- if (statePayload === null)
243
- return redirect(`${webOrigin}/settings/accounts?oauthError=invalid_state`);
244
- const { accountConfigId } = statePayload;
245
- const [firstService] = orderServices(statePayload.services);
246
- const config = getMsOAuthConfig();
247
- const serviceFor = (service) => createMailOAuthService(microsoftProviderConfig({
248
- clientId: credentials.clientId,
249
- clientSecret: credentials.clientSecret,
250
- services: [service],
251
- overrides: config.tokenEndpoint
252
- ? { tokenEndpoint: config.tokenEndpoint }
253
- : undefined,
254
- }));
255
- // Exchange the authorization code for tokens
256
- const tokenSet = await serviceFor(firstService)
257
- .exchangeCode(code, config.redirectUri)
258
- .catch((err) => {
259
- logger.error({ accountConfigId, error: inspect(err) }, "MS OAuth code exchange failed");
260
- return null;
261
- });
262
- if (tokenSet === null)
263
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
264
- if (!tokenSet.refreshToken) {
265
- logger.error({ accountConfigId }, "MS OAuth token exchange returned no refresh_token");
266
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
267
- }
268
- // Extract the user's email from the ID token returned alongside the access
269
- // token when openid+email scopes are requested.
270
- //
271
- // We prefer the ID token (idToken) because Outlook resource access tokens
272
- // (IMAP.AccessAsUser.All, SMTP.Send) are opaque/non-JWT and cannot be
273
- // decoded. The ID token is a standard OIDC token that always contains
274
- // identity claims (preferred_username / email).
275
- //
276
- // Falls back to parsing the access token only in test/dev environments where
277
- // idToken may not be present (e.g. custom token endpoints that don't issue
278
- // OIDC tokens).
279
- const tokenToParse = tokenSet.idToken ?? tokenSet.accessToken;
280
- const claims = await parseJwtClaims(tokenToParse);
281
- if (!claims) {
282
- // A non-JWT or unparseable token. The empty-email guard below turns
283
- // this into a user-facing missing_email redirect; log here so the parse
284
- // failure itself is observable (e.g. an unexpected token shape).
285
- logger.warn({
286
- alert: "oauth_callback_failed",
287
- reason: "jwt_parse_failed",
288
- accountConfigId,
289
- }, "MS OAuth callback: failed to parse identity token claims");
290
- }
291
- const email = claims
292
- ? (claims.preferred_username ??
293
- claims.email ??
294
- claims.upn)
295
- : undefined;
296
- if (!email) {
297
- logger.error({ accountConfigId }, "MS OAuth: could not extract email from token claims");
298
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_email`);
299
- }
300
- const { account, accountConfig, secrets } = await getClient();
301
- // Reconnect when an active OAuth account already onboards this mailbox.
302
- // Same natural key as the IMAP create guard (#635); the OAuth flow returns
303
- // the existing account (token refresh) rather than rejecting, because a
304
- // re-auth is the expected, idempotent path for OAuth.
305
- const existingAccounts = (await account.listAllByAccountConfig(accountConfigId)).filter((a) => a.authType === AccountAuthType.OauthMicrosoft);
306
- const existing = findActiveDuplicateMailbox(existingAccounts, {
307
- imapHost: OUTLOOK_IMAP_HOST,
308
- username: email,
309
- });
310
- const services = orderServices([
311
- ...statePayload.services,
312
- ...(existing?.syncedServices ?? []),
313
- ]);
314
- const redemption = await redeemEachService(serviceFor, tokenSet.refreshToken, services.filter((service) => service !== firstService), accountConfigId);
315
- if (redemption.kind === "refused" &&
316
- redemption.reason === "scope_not_granted")
317
- return redirect(scopeRefusalUrl(webOrigin, email, [redemption.service]));
318
- if (redemption.kind === "refused")
319
- return redirect(`${webOrigin}/settings/accounts?oauthError=${redemption.reason}`);
320
- const grantedScopes = [
321
- ...new Set([...tokenSet.grantedScopes, ...redemption.grantedScopes]),
322
- ];
323
- const grantedServices = microsoftServicesGranted(grantedScopes);
324
- const missingServices = services.filter((service) => !grantedServices.includes(service));
325
- if (missingServices.length > 0) {
326
- logger.warn({
327
- alert: "oauth_callback_failed",
328
- reason: "scope_not_granted",
329
- accountConfigId,
330
- missingServices,
331
- grantedScopes,
332
- }, "MS OAuth callback: granted scopes lack a requested service");
333
- return redirect(scopeRefusalUrl(webOrigin, email, missingServices));
334
- }
335
- // Ensure the account config row exists for this user
336
- await ensureAccountConfig(accountConfig, accountConfigId);
337
- // Encrypt the refresh token
338
- const tokenPayload = await secrets.encrypt(redemption.refreshToken);
339
- const oauthRefreshTokenHash = JSON.stringify(serializeEncryptedPayload(tokenPayload));
340
- const oauthTokenUpdatedAt = Date.now();
341
- let accountId;
342
- if (existing) {
343
- // Reconnect: update the stored token and clear any reauth_required state
344
- const updated = await account.update(existing.accountId, {
345
- oauthRefreshTokenHash,
346
- oauthTokenUpdatedAt,
347
- syncedServices: services,
348
- grantedScopes,
349
- connectionState: ConnectionState.NotAuthenticated,
350
- }, ["lastError"]);
351
- accountId = updated.accountId;
352
- // biome-ignore lint/plugin/no-logger-info: OAuth reconnect is an audit-grade signal
353
- logger.info({ accountConfigId, accountId }, "MS OAuth reconnect: refresh token updated");
354
- }
355
- else {
356
- // First connect: create a new account
357
- const newAccount = await account.create({
358
- accountConfigId,
359
- email,
360
- username: email,
361
- authType: AccountAuthType.OauthMicrosoft,
362
- syncedServices: services,
363
- grantedScopes,
364
- oauthRefreshTokenHash,
365
- oauthTokenUpdatedAt,
366
- imapHost: OUTLOOK_IMAP_HOST,
367
- imapPort: 993,
368
- imapTls: true,
369
- imapStartTls: false,
370
- smtpEnabled: true,
371
- smtpHost: "smtp.office365.com",
372
- smtpPort: 587,
373
- smtpTls: false,
374
- smtpStartTls: true,
375
- isActive: true,
376
- connectionState: ConnectionState.NotAuthenticated,
377
- });
378
- accountId = newAccount.accountId;
379
- // biome-ignore lint/plugin/no-logger-info: OAuth account creation is an audit-grade signal
380
- logger.info({ accountConfigId, accountId }, "MS OAuth: new account created");
381
- }
382
- if (services.includes(AccountService.Mail)) {
383
- await triggerAccountSyncSafe(accountId);
384
- }
385
- return redirect(`${webOrigin}/settings/accounts?connected=${encodeURIComponent(accountId)}`);
386
- },
387
334
  };
@@ -1,6 +1,15 @@
1
- import type { AccountDetailOperationIds, AccountOperationIds, OperationHandler } from "../types.js";
1
+ import type { AccountResponse, DeleteAccountResponse, TestConnectionResponse } from "@remit/api-openapi-types";
2
+ import type { Context } from "openapi-backend";
3
+ import type { RemitClient } from "../service/data-client.js";
4
+ import type { HandlerContext } from "../types.js";
2
5
  import { assertNotOAuthCreate, assertPasswordProvided, toAccountResponse } from "./account-guards.js";
3
6
  export { assertNotOAuthCreate, assertPasswordProvided, toAccountResponse };
4
- export declare const triggerAccountSyncSafe: (accountId: string) => Promise<void>;
5
- export declare const AccountOperations: Record<AccountOperationIds, OperationHandler<AccountOperationIds>>;
6
- export declare const AccountDetailOperations: Record<AccountDetailOperationIds, OperationHandler<AccountDetailOperationIds>>;
7
+ export declare const triggerAccountSyncSafe: (client: RemitClient<"sync">, accountId: string) => Promise<void>;
8
+ export declare const AccountOperations: {
9
+ AccountOperations_createAccount: (context: Context, ctx: HandlerContext<"sync">) => Promise<AccountResponse>;
10
+ AccountOperations_testConnection: (context: Context, ctx: HandlerContext) => Promise<TestConnectionResponse>;
11
+ };
12
+ export declare const AccountDetailOperations: {
13
+ AccountDetailOperations_updateAccount: (context: Context, ctx: HandlerContext) => Promise<AccountResponse>;
14
+ AccountDetailOperations_deleteAccount: (context: Context, ctx: HandlerContext<"accountFanout">) => Promise<DeleteAccountResponse>;
15
+ };
@@ -3,13 +3,10 @@ import { logger } from "@remit/logger";
3
3
  import { createMailOAuthService, microsoftProviderConfig, RefreshTokenError, } from "@remit/mail-oauth-service";
4
4
  import { ownedAccount, resolveConnectionCredentials, testImapConnection, testSmtpConnection, } from "@remit/mailbox-service";
5
5
  import { deserializeEncryptedPayload, serializeEncryptedPayload, } from "@remit/secrets-service";
6
- import { env } from "expect-env";
7
- import { getAccountConfigIdFromEvent } from "../auth.js";
6
+ import { loadMsOAuth } from "../config/msoauth.js";
8
7
  import { deleteAccount } from "../service/account-deletion.js";
9
- import { getClient } from "../service/data-client.js";
10
8
  import { fireAndForget } from "../service/fire-and-forget.js";
11
9
  import { unwrap } from "../service/refusal.js";
12
- import { sqsClient } from "../service/sqs.js";
13
10
  import { triggerAccountSync } from "../service/trigger-sync.js";
14
11
  import { assertNoDuplicateMailbox, assertNotOAuthCreate, assertPasswordProvided, resolveSyncedServices, toAccountResponse, } from "./account-guards.js";
15
12
  import { loadAccountOverrides, upsertAccountDisplayName, writeAccountComposeLanguages, writeAccountMuted, } from "./account-overrides.js";
@@ -17,8 +14,7 @@ import { loadSignatureForAccount, upsertAccountSignature, } from "./account-sign
17
14
  import { ensureAccountConfig } from "./ensure-account-config.js";
18
15
  import { resolveAccountFolderAppointments } from "./folder-role-appointments.js";
19
16
  export { assertNotOAuthCreate, assertPasswordProvided, toAccountResponse };
20
- export const triggerAccountSyncSafe = async (accountId) => {
21
- const queueUrl = env.SQS_QUEUE_URL;
17
+ export const triggerAccountSyncSafe = async (client, accountId) => {
22
18
  // Best-effort: account creation must still return 200 even if the sync
23
19
  // enqueue fails. fireAndForget catches and logs every rejection loudly with
24
20
  // the alertable structured fields and never rejects, so a failure here (SQS
@@ -26,8 +22,7 @@ export const triggerAccountSyncSafe = async (accountId) => {
26
22
  // unhandled rejection onto an unrelated in-flight request.
27
23
  await fireAndForget(async () => {
28
24
  const { eventId } = await triggerAccountSync({
29
- sqsClient,
30
- queueUrl,
25
+ queue: client.queues.sync,
31
26
  accountId,
32
27
  });
33
28
  // biome-ignore lint/plugin/no-logger-info: sync-triggered-for-new-account is an audit-grade signal
@@ -48,16 +43,13 @@ export const triggerAccountSyncSafe = async (accountId) => {
48
43
  * A revoked / expired refresh token surfaces as RefreshTokenError; the
49
44
  * reauth-required case is mapped to the distinct `reauth_required` error code
50
45
  * so the UI can route the user into the OAuth re-auth flow.
51
- *
52
- * MSOAUTH_* env vars are only present in deployed Lambdas; fall back to "".
53
46
  */
54
- const testOAuthConnection = async (existingAccount, account, secrets) => {
47
+ const testOAuthConnection = async (msOAuth, existingAccount, account, secrets) => {
48
+ const { config, credentials: app } = await loadMsOAuth(msOAuth);
55
49
  const tokenService = createMailOAuthService(microsoftProviderConfig({
56
- clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
57
- clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
58
- overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
59
- ? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
60
- : undefined,
50
+ clientId: app.clientId,
51
+ clientSecret: app.clientSecret,
52
+ overrides: config.overrides,
61
53
  }));
62
54
  let resolution;
63
55
  try {
@@ -123,16 +115,15 @@ const testOAuthConnection = async (existingAccount, account, secrets) => {
123
115
  return result;
124
116
  };
125
117
  export const AccountOperations = {
126
- AccountOperations_createAccount: async (_context, ...args) => {
127
- const event = args[0];
128
- const accountConfigId = getAccountConfigIdFromEvent(event);
129
- const input = JSON.parse(event.body ?? "{}");
118
+ AccountOperations_createAccount: async (context, ctx) => {
119
+ const accountConfigId = ctx.identity.accountConfigId;
120
+ const input = context.request.requestBody;
130
121
  // OAuth accounts must be created via the dedicated connect flow
131
122
  assertNotOAuthCreate(input.authType);
132
123
  // Password accounts require a non-empty password
133
124
  assertPasswordProvided(input.authType, input.password);
134
125
  const syncedServices = resolveSyncedServices({ authType: AccountAuthType.Password, grantedScopes: [] }, input.syncedServices ?? [AccountService.Mail]);
135
- const { account, accountConfig, accountSetting, mailbox, secrets } = await getClient();
126
+ const { account, accountConfig, accountSetting, mailbox, secrets } = ctx.client;
136
127
  await ensureAccountConfig(accountConfig, accountConfigId);
137
128
  // Reject re-onboarding the same mailbox-in-the-same-place. Message
138
129
  // identity is account-scoped (#633), so duplicate onboards are no longer
@@ -180,18 +171,17 @@ export const AccountOperations = {
180
171
  if (input.displayName !== undefined) {
181
172
  await upsertAccountDisplayName(accountSetting, accountConfigId, newAccount.accountId, input.displayName);
182
173
  }
183
- await triggerAccountSyncSafe(newAccount.accountId);
174
+ await triggerAccountSyncSafe(ctx.client, newAccount.accountId);
184
175
  const [overrides, folderAppointments] = await Promise.all([
185
176
  loadAccountOverrides(accountSetting, accountConfigId, newAccount.accountId),
186
177
  resolveAccountFolderAppointments({ mailbox, accountSetting }, accountConfigId, newAccount.accountId),
187
178
  ]);
188
179
  return toAccountResponse(newAccount, {}, overrides, folderAppointments);
189
180
  },
190
- AccountOperations_testConnection: async (_context, ...args) => {
191
- const event = args[0];
192
- const accountConfigId = getAccountConfigIdFromEvent(event);
193
- const input = JSON.parse(event.body ?? "{}");
194
- const { account, secrets } = await getClient();
181
+ AccountOperations_testConnection: async (context, ctx) => {
182
+ const accountConfigId = ctx.identity.accountConfigId;
183
+ const input = context.request.requestBody;
184
+ const { account, secrets } = ctx.client;
195
185
  // Resolve password - use provided password or decrypt stored password
196
186
  let imapPassword = input.password;
197
187
  let smtpPassword = input.smtpPassword;
@@ -200,7 +190,7 @@ export const AccountOperations = {
200
190
  unwrap(ownedAccount(existingAccount, accountConfigId, "read"));
201
191
  const authType = existingAccount.authType ?? "password";
202
192
  if (authType === "oauthMicrosoft") {
203
- return testOAuthConnection(existingAccount, account, secrets);
193
+ return testOAuthConnection(ctx.config.msOAuth, existingAccount, account, secrets);
204
194
  }
205
195
  if (existingAccount.passwordHash) {
206
196
  const payload = deserializeEncryptedPayload(JSON.parse(existingAccount.passwordHash));
@@ -260,12 +250,11 @@ const encryptedSecret = async (secrets, plaintext) => plaintext
260
250
  ? JSON.stringify(serializeEncryptedPayload(await secrets.encrypt(plaintext)))
261
251
  : undefined;
262
252
  export const AccountDetailOperations = {
263
- AccountDetailOperations_updateAccount: async (context, ...args) => {
264
- const event = args[0];
265
- const accountConfigId = getAccountConfigIdFromEvent(event);
253
+ AccountDetailOperations_updateAccount: async (context, ctx) => {
254
+ const accountConfigId = ctx.identity.accountConfigId;
266
255
  const { accountId } = context.request.params;
267
- const input = JSON.parse(event.body ?? "{}");
268
- const { account, accountSetting, mailbox, secrets } = await getClient();
256
+ const input = context.request.requestBody;
257
+ const { account, accountSetting, mailbox, secrets } = ctx.client;
269
258
  const existing = await account.get(accountId);
270
259
  unwrap(ownedAccount(existing, accountConfigId, "act"));
271
260
  const entityFields = {
@@ -327,18 +316,16 @@ export const AccountDetailOperations = {
327
316
  ]);
328
317
  return toAccountResponse(updated, signature, overrides, folderAppointments);
329
318
  },
330
- AccountDetailOperations_deleteAccount: async (context, ...args) => {
331
- const event = args[0];
332
- const accountConfigId = getAccountConfigIdFromEvent(event);
319
+ AccountDetailOperations_deleteAccount: async (context, ctx) => {
320
+ const accountConfigId = ctx.identity.accountConfigId;
333
321
  const { accountId } = context.request.params;
334
- const { account } = await getClient();
322
+ const { account } = ctx.client;
335
323
  const existing = await account.get(accountId);
336
324
  unwrap(ownedAccount(existing, accountConfigId, "act"));
337
325
  const deletedAt = Date.now();
338
326
  await deleteAccount({
339
327
  account,
340
- sqsClient,
341
- accountFanoutQueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
328
+ queue: ctx.client.queues.accountFanout,
342
329
  log: logger,
343
330
  }, existing, deletedAt);
344
331
  return {