@remit/backend 0.0.151 → 0.0.153

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.
@@ -1,12 +1,23 @@
1
- import type { OAuthProviderConfig } from "@remit/mail-oauth-service";
1
+ import { type MailOAuthService, type OAuthProviderConfig } from "@remit/mail-oauth-service";
2
2
  export interface MsOAuthCredentials {
3
3
  clientId: string;
4
4
  clientSecret: string;
5
5
  }
6
+ /** Where a connected Outlook account's mail server is reached instead of Microsoft's. */
7
+ export interface MailServerOverride {
8
+ host: string;
9
+ port: number;
10
+ tls: boolean;
11
+ startTls: boolean;
12
+ }
13
+ export interface MsOAuthOverrides extends Partial<Pick<OAuthProviderConfig, "tokenEndpoint">> {
14
+ imapServer?: MailServerOverride;
15
+ smtpServer?: MailServerOverride;
16
+ }
6
17
  export interface MsOAuthEnabled {
7
18
  mode: "Enabled";
8
19
  redirectUri: string;
9
- overrides: Partial<Pick<OAuthProviderConfig, "tokenEndpoint">>;
20
+ overrides: MsOAuthOverrides;
10
21
  credentials: () => Promise<MsOAuthCredentials>;
11
22
  }
12
23
  export type MsOAuthConfig = {
@@ -17,3 +28,10 @@ export interface LoadedMsOAuth {
17
28
  credentials: MsOAuthCredentials;
18
29
  }
19
30
  export declare const loadMsOAuth: (config: MsOAuthConfig) => Promise<LoadedMsOAuth>;
31
+ export type MicrosoftTokenService = Pick<MailOAuthService, "getAccessToken">;
32
+ /**
33
+ * The access-token minting a connected Outlook account syncs and sends with.
34
+ * The app's credentials load on the first token asked for, and once loaded the
35
+ * service keeps its token cache for every account after it.
36
+ */
37
+ export declare const microsoftTokenService: (msOAuth: MsOAuthConfig) => MicrosoftTokenService;
@@ -1,6 +1,29 @@
1
+ import { createMailOAuthService, microsoftProviderConfig, } from "@remit/mail-oauth-service";
1
2
  export const loadMsOAuth = async (config) => {
2
3
  if (config.mode === "Disabled") {
3
4
  throw new Error("No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN");
4
5
  }
5
6
  return { config, credentials: await config.credentials() };
6
7
  };
8
+ /**
9
+ * The access-token minting a connected Outlook account syncs and sends with.
10
+ * The app's credentials load on the first token asked for, and once loaded the
11
+ * service keeps its token cache for every account after it.
12
+ */
13
+ export const microsoftTokenService = (msOAuth) => {
14
+ let service;
15
+ const load = () => loadMsOAuth(msOAuth).then(({ config, credentials }) => createMailOAuthService(microsoftProviderConfig({
16
+ clientId: credentials.clientId,
17
+ clientSecret: credentials.clientSecret,
18
+ overrides: config.overrides,
19
+ })));
20
+ return {
21
+ getAccessToken: (cacheKey, refreshToken) => {
22
+ service ??= load().catch((error) => {
23
+ service = undefined;
24
+ throw error;
25
+ });
26
+ return service.then((loaded) => loaded.getAccessToken(cacheKey, refreshToken));
27
+ },
28
+ };
29
+ };
@@ -1,20 +1,17 @@
1
- import type { AccountService as AccountServiceName } from "@remit/api-openapi-types";
2
1
  import type { Context } from "openapi-backend";
3
2
  import type { HandlerContext, PublicOperationHandler } from "../types.js";
4
- export interface OAuthState {
5
- accountConfigId: string;
6
- nonce: string;
7
- timestamp: number;
8
- services: AccountServiceName[];
9
- }
10
- export declare const STATE_TTL_MS: number;
11
- export declare function signState(payload: OAuthState, clientSecret: string): Promise<string>;
12
- export declare function verifyState(state: string, clientSecret: string): Promise<OAuthState>;
13
3
  export declare function parseJwtClaims(token: string): Promise<Record<string, unknown> | null>;
14
4
  export declare function getWebOrigin(origins: readonly string[]): string;
15
5
  export declare const microsoftOAuthCallback: PublicOperationHandler<"sync">;
6
+ export interface SignInNotConfigured {
7
+ statusCode: 501;
8
+ body: {
9
+ code: "not_configured";
10
+ message: string;
11
+ };
12
+ }
16
13
  export declare const MicrosoftOAuthOperations: {
17
14
  MicrosoftOAuthOperations_microsoftOAuthStart: (context: Context, ctx: HandlerContext) => Promise<{
18
15
  authorizationUrl: string;
19
- }>;
16
+ } | SignInNotConfigured>;
20
17
  };
@@ -1,4 +1,4 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { randomBytes } from "node:crypto";
2
2
  import { ensureAccountConfig } from "@remit/data-ports/account-config";
3
3
  import { AccountAuthType, AccountService, ConnectionState, } from "@remit/domain-enums";
4
4
  import { logger } from "@remit/logger";
@@ -6,63 +6,22 @@ import { createMailOAuthService, microsoftProviderConfig, microsoftServicesGrant
6
6
  import { serializeEncryptedPayload } from "@remit/secrets-service";
7
7
  import { loadMsOAuth } from "../config/msoauth.js";
8
8
  import { safeJsonParse } from "../json.js";
9
+ import { ACCOUNT_SETTINGS_ROUTE, oauthRefusalUrl, signState, verifyState, } from "../oauth-state.js";
9
10
  import { rawApiResponse } from "../response.js";
10
11
  import { triggerAccountSyncSafe } from "./account.js";
11
12
  import { findActiveDuplicateMailbox } from "./account-guards.js";
12
- const OUTLOOK_IMAP_HOST = "outlook.office365.com";
13
- const ACCOUNT_SERVICES = Object.values(AccountService);
14
- const isServiceList = (value) => Array.isArray(value) &&
15
- value.length > 0 &&
16
- value.every((service) => typeof service === "string" && ACCOUNT_SERVICES.includes(service));
17
- export const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
18
- /**
19
- * Derive a signing key from the client secret, namespaced to avoid reuse.
20
- * The key is HMAC-SHA256("oauth-state-signing:" + clientSecret).
21
- */
22
- function deriveSigningKey(clientSecret) {
23
- return createHmac("sha256", `oauth-state-signing:${clientSecret}`)
24
- .update("")
25
- .digest();
26
- }
27
- function base64urlEncode(buf) {
28
- return buf.toString("base64url");
29
- }
30
- function base64urlDecode(str) {
31
- return Buffer.from(str, "base64url");
32
- }
33
- export async function signState(payload, clientSecret) {
34
- const key = deriveSigningKey(clientSecret);
35
- const payloadBuf = Buffer.from(JSON.stringify(payload), "utf8");
36
- const payloadEncoded = base64urlEncode(payloadBuf);
37
- const sig = createHmac("sha256", key).update(payloadEncoded).digest();
38
- const sigEncoded = base64urlEncode(sig);
39
- return `${payloadEncoded}.${sigEncoded}`;
40
- }
41
- export async function verifyState(state, clientSecret) {
42
- const dotIndex = state.lastIndexOf(".");
43
- if (dotIndex < 0)
44
- throw new Error("Malformed state: missing dot separator");
45
- const payloadEncoded = state.slice(0, dotIndex);
46
- const sigEncoded = state.slice(dotIndex + 1);
47
- const key = deriveSigningKey(clientSecret);
48
- const expectedSig = createHmac("sha256", key).update(payloadEncoded).digest();
49
- const actualSig = base64urlDecode(sigEncoded);
50
- if (actualSig.length !== expectedSig.length ||
51
- !timingSafeEqual(actualSig, expectedSig)) {
52
- throw new Error("State signature verification failed");
53
- }
54
- const payload = JSON.parse(base64urlDecode(payloadEncoded).toString("utf8"));
55
- if (Date.now() - payload.timestamp > STATE_TTL_MS) {
56
- throw new Error("State has expired");
57
- }
58
- if (payload.services === undefined) {
59
- return { ...payload, services: [AccountService.Mail] };
60
- }
61
- if (!isServiceList(payload.services)) {
62
- throw new Error("State names no requested services");
63
- }
64
- return payload;
65
- }
13
+ const OUTLOOK_IMAP = {
14
+ host: "outlook.office365.com",
15
+ port: 993,
16
+ tls: true,
17
+ startTls: false,
18
+ };
19
+ const OUTLOOK_SMTP = {
20
+ host: "smtp.office365.com",
21
+ port: 587,
22
+ tls: false,
23
+ startTls: true,
24
+ };
66
25
  // ─── JWT claims parsing ──────────────────────────────────────────────────────
67
26
  export async function parseJwtClaims(token) {
68
27
  const parts = token.split(".");
@@ -77,7 +36,7 @@ export function getWebOrigin(origins) {
77
36
  return httpsOrigin ?? origins[0] ?? "https://localhost:3000";
78
37
  }
79
38
  const scopeRefusalUrl = (webOrigin, email, missingServices) => {
80
- const url = new URL(`${webOrigin}/settings/accounts`);
39
+ const url = new URL(`${webOrigin}${ACCOUNT_SETTINGS_ROUTE}`);
81
40
  url.searchParams.set("oauthError", "scope_not_granted");
82
41
  url.searchParams.set("oauthEmail", email);
83
42
  url.searchParams.set("missingServices", missingServices.join(","));
@@ -125,13 +84,12 @@ export const microsoftOAuthCallback = async (context, ctx) => {
125
84
  });
126
85
  // If Microsoft returned an error, pass it through to the frontend
127
86
  if (qs.error) {
128
- const errorCode = encodeURIComponent(qs.error);
129
- return redirect(`${webOrigin}/settings/accounts?oauthError=${errorCode}`);
87
+ return redirect(oauthRefusalUrl(webOrigin, qs.error));
130
88
  }
131
89
  const code = qs.code;
132
90
  const state = qs.state;
133
91
  if (!code || !state) {
134
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_params`);
92
+ return redirect(oauthRefusalUrl(webOrigin, "missing_params"));
135
93
  }
136
94
  const loaded = await loadMsOAuth(ctx.config.msOAuth).catch((err) => {
137
95
  logger.error({
@@ -144,7 +102,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
144
102
  return null;
145
103
  });
146
104
  if (loaded === null)
147
- return redirect(`${webOrigin}/settings/accounts?oauthError=config_error`);
105
+ return redirect(oauthRefusalUrl(webOrigin, "config_error"));
148
106
  const { config, credentials } = loaded;
149
107
  // Verify the HMAC-signed state
150
108
  // A bad/expired/forged state is a security-relevant signal (CSRF
@@ -160,7 +118,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
160
118
  return null;
161
119
  });
162
120
  if (statePayload === null)
163
- return redirect(`${webOrigin}/settings/accounts?oauthError=invalid_state`);
121
+ return redirect(oauthRefusalUrl(webOrigin, "invalid_state"));
164
122
  const { accountConfigId } = statePayload;
165
123
  const [firstService] = orderServices(statePayload.services);
166
124
  const serviceFor = (service) => createMailOAuthService(microsoftProviderConfig({
@@ -177,10 +135,10 @@ export const microsoftOAuthCallback = async (context, ctx) => {
177
135
  return null;
178
136
  });
179
137
  if (tokenSet === null)
180
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
138
+ return redirect(oauthRefusalUrl(webOrigin, "exchange_failed"));
181
139
  if (!tokenSet.refreshToken) {
182
140
  logger.error({ accountConfigId }, "MS OAuth token exchange returned no refresh_token");
183
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
141
+ return redirect(oauthRefusalUrl(webOrigin, "exchange_failed"));
184
142
  }
185
143
  // Extract the user's email from the ID token returned alongside the access
186
144
  // token when openid+email scopes are requested.
@@ -212,7 +170,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
212
170
  : undefined;
213
171
  if (!email) {
214
172
  logger.error({ accountConfigId }, "MS OAuth: could not extract email from token claims");
215
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_email`);
173
+ return redirect(oauthRefusalUrl(webOrigin, "missing_email"));
216
174
  }
217
175
  const { account, accountConfig, secrets } = ctx.client;
218
176
  // Reconnect when an active OAuth account already onboards this mailbox.
@@ -220,8 +178,10 @@ export const microsoftOAuthCallback = async (context, ctx) => {
220
178
  // the existing account (token refresh) rather than rejecting, because a
221
179
  // re-auth is the expected, idempotent path for OAuth.
222
180
  const existingAccounts = (await account.listAllByAccountConfig(accountConfigId)).filter((a) => a.authType === AccountAuthType.OauthMicrosoft);
181
+ const imapServer = config.overrides.imapServer ?? OUTLOOK_IMAP;
182
+ const smtpServer = config.overrides.smtpServer ?? OUTLOOK_SMTP;
223
183
  const existing = findActiveDuplicateMailbox(existingAccounts, {
224
- imapHost: OUTLOOK_IMAP_HOST,
184
+ imapHost: imapServer.host,
225
185
  username: email,
226
186
  });
227
187
  const services = orderServices([
@@ -233,7 +193,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
233
193
  redemption.reason === "scope_not_granted")
234
194
  return redirect(scopeRefusalUrl(webOrigin, email, [redemption.service]));
235
195
  if (redemption.kind === "refused")
236
- return redirect(`${webOrigin}/settings/accounts?oauthError=${redemption.reason}`);
196
+ return redirect(oauthRefusalUrl(webOrigin, redemption.reason));
237
197
  const grantedScopes = [
238
198
  ...new Set([...tokenSet.grantedScopes, ...redemption.grantedScopes]),
239
199
  ];
@@ -280,15 +240,15 @@ export const microsoftOAuthCallback = async (context, ctx) => {
280
240
  grantedScopes,
281
241
  oauthRefreshTokenHash,
282
242
  oauthTokenUpdatedAt,
283
- imapHost: OUTLOOK_IMAP_HOST,
284
- imapPort: 993,
285
- imapTls: true,
286
- imapStartTls: false,
243
+ imapHost: imapServer.host,
244
+ imapPort: imapServer.port,
245
+ imapTls: imapServer.tls,
246
+ imapStartTls: imapServer.startTls,
287
247
  smtpEnabled: true,
288
- smtpHost: "smtp.office365.com",
289
- smtpPort: 587,
290
- smtpTls: false,
291
- smtpStartTls: true,
248
+ smtpHost: smtpServer.host,
249
+ smtpPort: smtpServer.port,
250
+ smtpTls: smtpServer.tls,
251
+ smtpStartTls: smtpServer.startTls,
292
252
  isActive: true,
293
253
  connectionState: ConnectionState.NotAuthenticated,
294
254
  });
@@ -299,10 +259,24 @@ export const microsoftOAuthCallback = async (context, ctx) => {
299
259
  if (services.includes(AccountService.Mail)) {
300
260
  await triggerAccountSyncSafe(ctx.client, accountId);
301
261
  }
302
- return redirect(`${webOrigin}/settings/accounts?connected=${encodeURIComponent(accountId)}`);
262
+ return redirect(`${webOrigin}${ACCOUNT_SETTINGS_ROUTE}?connected=${encodeURIComponent(accountId)}`);
303
263
  };
264
+ /**
265
+ * An instance with no Microsoft app answers the start of a sign-in with a
266
+ * refusal the onboarding wizard can show, rather than the 500 the config load
267
+ * would otherwise throw.
268
+ */
269
+ const signInNotConfigured = () => ({
270
+ statusCode: 501,
271
+ body: {
272
+ code: "not_configured",
273
+ message: "Microsoft sign-in is not configured on this instance; connect the account with an app password instead.",
274
+ },
275
+ });
304
276
  export const MicrosoftOAuthOperations = {
305
277
  MicrosoftOAuthOperations_microsoftOAuthStart: async (context, ctx) => {
278
+ if (ctx.config.msOAuth.mode === "Disabled")
279
+ return signInNotConfigured();
306
280
  const accountConfigId = ctx.identity.accountConfigId;
307
281
  const input = context.request.requestBody;
308
282
  const email = typeof input.email === "string" ? input.email : undefined;
@@ -162,7 +162,7 @@ export declare const handlers: {
162
162
  MailboxOperations_createMailbox: (context: import("openapi-backend").Context<any, any, any, any, any, import("openapi-backend").Document>, ctx: import("../types.js").HandlerContext<"sync">) => Promise<import("@remit/api-openapi-types").Components.Schemas.MailboxResponse>;
163
163
  MicrosoftOAuthOperations_microsoftOAuthStart: (context: import("openapi-backend").Context, ctx: import("../types.js").HandlerContext) => Promise<{
164
164
  authorizationUrl: string;
165
- }>;
165
+ } | import("./account-oauth.js").SignInNotConfigured>;
166
166
  AccountDetailOperations_updateAccount: (context: import("openapi-backend").Context, ctx: import("../types.js").HandlerContext) => Promise<import("@remit/api-openapi-types").AccountResponse>;
167
167
  AccountDetailOperations_deleteAccount: (context: import("openapi-backend").Context, ctx: import("../types.js").HandlerContext<"accountFanout">) => Promise<import("@remit/api-openapi-types").DeleteAccountResponse>;
168
168
  AccountOperations_createAccount: (context: import("openapi-backend").Context, ctx: import("../types.js").HandlerContext<"sync">) => Promise<import("@remit/api-openapi-types").AccountResponse>;
@@ -0,0 +1,14 @@
1
+ import type { AccountService as AccountServiceName } from "@remit/api-openapi-types";
2
+ export interface OAuthState {
3
+ accountConfigId: string;
4
+ nonce: string;
5
+ timestamp: number;
6
+ services: AccountServiceName[];
7
+ }
8
+ export declare const STATE_TTL_MS: number;
9
+ export declare function signState(payload: OAuthState, clientSecret: string): Promise<string>;
10
+ export declare function verifyState(state: string, clientSecret: string): Promise<OAuthState>;
11
+ /** The web client's account settings, where every Microsoft sign-in lands. */
12
+ export declare const ACCOUNT_SETTINGS_ROUTE = "/settings/accounts";
13
+ /** Where a refused Microsoft sign-in sends the browser back to, carrying why. */
14
+ export declare const oauthRefusalUrl: (webOrigin: string, oauthError: string) => string;
@@ -0,0 +1,63 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { AccountService } from "@remit/domain-enums";
3
+ const ACCOUNT_SERVICES = Object.values(AccountService);
4
+ const isServiceList = (value) => Array.isArray(value) &&
5
+ value.length > 0 &&
6
+ value.every((service) => typeof service === "string" && ACCOUNT_SERVICES.includes(service));
7
+ export const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
8
+ /**
9
+ * Derive a signing key from the client secret, namespaced to avoid reuse.
10
+ * The key is HMAC-SHA256("oauth-state-signing:" + clientSecret).
11
+ */
12
+ function deriveSigningKey(clientSecret) {
13
+ return createHmac("sha256", `oauth-state-signing:${clientSecret}`)
14
+ .update("")
15
+ .digest();
16
+ }
17
+ function base64urlEncode(buf) {
18
+ return buf.toString("base64url");
19
+ }
20
+ function base64urlDecode(str) {
21
+ return Buffer.from(str, "base64url");
22
+ }
23
+ export async function signState(payload, clientSecret) {
24
+ const key = deriveSigningKey(clientSecret);
25
+ const payloadBuf = Buffer.from(JSON.stringify(payload), "utf8");
26
+ const payloadEncoded = base64urlEncode(payloadBuf);
27
+ const sig = createHmac("sha256", key).update(payloadEncoded).digest();
28
+ const sigEncoded = base64urlEncode(sig);
29
+ return `${payloadEncoded}.${sigEncoded}`;
30
+ }
31
+ export async function verifyState(state, clientSecret) {
32
+ const dotIndex = state.lastIndexOf(".");
33
+ if (dotIndex < 0)
34
+ throw new Error("Malformed state: missing dot separator");
35
+ const payloadEncoded = state.slice(0, dotIndex);
36
+ const sigEncoded = state.slice(dotIndex + 1);
37
+ const key = deriveSigningKey(clientSecret);
38
+ const expectedSig = createHmac("sha256", key).update(payloadEncoded).digest();
39
+ const actualSig = base64urlDecode(sigEncoded);
40
+ if (actualSig.length !== expectedSig.length ||
41
+ !timingSafeEqual(actualSig, expectedSig)) {
42
+ throw new Error("State signature verification failed");
43
+ }
44
+ const payload = JSON.parse(base64urlDecode(payloadEncoded).toString("utf8"));
45
+ if (Date.now() - payload.timestamp > STATE_TTL_MS) {
46
+ throw new Error("State has expired");
47
+ }
48
+ if (payload.services === undefined) {
49
+ return { ...payload, services: [AccountService.Mail] };
50
+ }
51
+ if (!isServiceList(payload.services)) {
52
+ throw new Error("State names no requested services");
53
+ }
54
+ return payload;
55
+ }
56
+ /** The web client's account settings, where every Microsoft sign-in lands. */
57
+ export const ACCOUNT_SETTINGS_ROUTE = "/settings/accounts";
58
+ /** Where a refused Microsoft sign-in sends the browser back to, carrying why. */
59
+ export const oauthRefusalUrl = (webOrigin, oauthError) => {
60
+ const url = new URL(`${webOrigin}${ACCOUNT_SETTINGS_ROUTE}`);
61
+ url.searchParams.set("oauthError", oauthError);
62
+ return url.toString();
63
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.151",
3
+ "version": "0.0.153",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -63,6 +63,14 @@
63
63
  "types": "./dist/src/service/trigger-sync.d.ts",
64
64
  "default": "./dist/src/service/trigger-sync.js"
65
65
  },
66
+ "./oauth-state": {
67
+ "types": "./dist/src/oauth-state.d.ts",
68
+ "default": "./dist/src/oauth-state.js"
69
+ },
70
+ "./msoauth": {
71
+ "types": "./dist/src/config/msoauth.d.ts",
72
+ "default": "./dist/src/config/msoauth.js"
73
+ },
66
74
  "./content-handler": {
67
75
  "types": "./dist/dev-server/content-handler.d.ts",
68
76
  "default": "./dist/dev-server/content-handler.js"