@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.
- package/dist/src/api.d.ts +0 -2
- package/dist/src/api.js +9 -80
- package/dist/src/auth.d.ts +13 -19
- package/dist/src/auth.js +51 -43
- package/dist/src/config/api-config.d.ts +20 -0
- package/dist/src/config/api-config.js +1 -0
- package/dist/src/config/from-env.d.ts +2 -0
- package/dist/src/config/from-env.js +65 -0
- package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
- package/dist/src/config/msoauth-secrets-manager.js +28 -0
- package/dist/src/config/msoauth.d.ts +18 -29
- package/dist/src/config/msoauth.js +4 -28
- package/dist/src/derive/contentSignature.d.ts +3 -4
- package/dist/src/derive/contentSignature.js +4 -13
- package/dist/src/derive/contentUrl.d.ts +0 -13
- package/dist/src/derive/contentUrl.js +0 -19
- package/dist/src/handlers/account-oauth.d.ts +9 -3
- package/dist/src/handlers/account-oauth.js +197 -250
- package/dist/src/handlers/account.d.ts +13 -4
- package/dist/src/handlers/account.js +26 -39
- package/dist/src/handlers/address.d.ts +11 -3
- package/dist/src/handlers/address.js +11 -19
- package/dist/src/handlers/calendar-event.js +18 -26
- package/dist/src/handlers/calendar-feed.d.ts +2 -2
- package/dist/src/handlers/calendar-feed.js +24 -32
- package/dist/src/handlers/calendar-suggestion.js +18 -26
- package/dist/src/handlers/calendar.d.ts +1 -1
- package/dist/src/handlers/calendar.js +15 -22
- package/dist/src/handlers/config.d.ts +16 -6
- package/dist/src/handlers/config.js +23 -35
- package/dist/src/handlers/filter.d.ts +16 -3
- package/dist/src/handlers/filter.js +17 -32
- package/dist/src/handlers/folder-role.js +3 -6
- package/dist/src/handlers/index.d.ts +187 -2
- package/dist/src/handlers/index.js +6 -4
- package/dist/src/handlers/label.js +15 -22
- package/dist/src/handlers/mailbox.d.ts +20 -4
- package/dist/src/handlers/mailbox.js +18 -26
- package/dist/src/handlers/me.d.ts +10 -2
- package/dist/src/handlers/me.js +26 -47
- package/dist/src/handlers/message.d.ts +78 -6
- package/dist/src/handlers/message.js +34 -46
- package/dist/src/handlers/organize.d.ts +10 -4
- package/dist/src/handlers/organize.js +11 -20
- package/dist/src/handlers/outbox-attachment.d.ts +6 -5
- package/dist/src/handlers/outbox-attachment.js +11 -15
- package/dist/src/handlers/outbox.d.ts +25 -4
- package/dist/src/handlers/outbox.js +27 -35
- package/dist/src/handlers/sync.d.ts +19 -6
- package/dist/src/handlers/sync.js +13 -22
- package/dist/src/handlers/system-update.js +14 -27
- package/dist/src/handlers/thread.js +9 -14
- package/dist/src/handlers/unified-threads.js +3 -6
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +18 -2
- package/dist/src/jwt-auth.d.ts +9 -12
- package/dist/src/jwt-auth.js +19 -71
- package/dist/src/lambda.d.ts +13 -0
- package/dist/src/lambda.js +33 -0
- package/dist/src/request.d.ts +8 -2
- package/dist/src/request.js +16 -6
- package/dist/src/response.d.ts +20 -3
- package/dist/src/response.js +15 -22
- package/dist/src/serve.d.ts +31 -0
- package/dist/src/serve.js +117 -0
- package/dist/src/service/account-deletion.d.ts +2 -4
- package/dist/src/service/account-deletion.js +3 -9
- package/dist/src/service/compose-sqlite.d.ts +2 -1
- package/dist/src/service/compose-sqlite.js +5 -3
- package/dist/src/service/create-remit-client.d.ts +25 -11
- package/dist/src/service/create-remit-client.js +97 -116
- package/dist/src/service/data-client.js +3 -5
- package/dist/src/service/export-identity.d.ts +2 -1
- package/dist/src/service/export-identity.js +4 -6
- package/dist/src/service/fire-and-forget.js +1 -2
- package/dist/src/service/organize.d.ts +7 -10
- package/dist/src/service/organize.js +10 -27
- package/dist/src/service/queues.d.ts +12 -0
- package/dist/src/service/queues.js +1 -0
- package/dist/src/service/shared-deps.d.ts +6 -0
- package/dist/src/service/shared-deps.js +26 -0
- package/dist/src/service/trigger-sync.d.ts +11 -5
- package/dist/src/service/trigger-sync.js +10 -18
- package/dist/src/types.d.ts +24 -1
- package/package.json +24 -8
- package/dist/src/data-backend.d.ts +0 -12
- package/dist/src/data-backend.js +0 -12
- package/dist/src/request-context.d.ts +0 -9
- package/dist/src/request-context.js +0 -22
- package/dist/src/service/sqs.d.ts +0 -1
- 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 {
|
|
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:
|
|
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 (
|
|
162
|
-
const
|
|
163
|
-
const
|
|
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 =
|
|
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.
|
|
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 {
|
|
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:
|
|
6
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
57
|
-
clientSecret:
|
|
58
|
-
overrides:
|
|
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 (
|
|
127
|
-
const
|
|
128
|
-
const
|
|
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 } =
|
|
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 (
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const
|
|
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,
|
|
264
|
-
const
|
|
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 =
|
|
268
|
-
const { account, accountSetting, mailbox, secrets } =
|
|
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,
|
|
331
|
-
const
|
|
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 } =
|
|
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
|
-
|
|
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 {
|