@remit/backend 0.0.152 → 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
+ };
@@ -6,11 +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 { signState, verifyState } from "../oauth-state.js";
9
+ import { ACCOUNT_SETTINGS_ROUTE, oauthRefusalUrl, signState, verifyState, } from "../oauth-state.js";
10
10
  import { rawApiResponse } from "../response.js";
11
11
  import { triggerAccountSyncSafe } from "./account.js";
12
12
  import { findActiveDuplicateMailbox } from "./account-guards.js";
13
- const OUTLOOK_IMAP_HOST = "outlook.office365.com";
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
+ };
14
25
  // ─── JWT claims parsing ──────────────────────────────────────────────────────
15
26
  export async function parseJwtClaims(token) {
16
27
  const parts = token.split(".");
@@ -25,7 +36,7 @@ export function getWebOrigin(origins) {
25
36
  return httpsOrigin ?? origins[0] ?? "https://localhost:3000";
26
37
  }
27
38
  const scopeRefusalUrl = (webOrigin, email, missingServices) => {
28
- const url = new URL(`${webOrigin}/settings/accounts`);
39
+ const url = new URL(`${webOrigin}${ACCOUNT_SETTINGS_ROUTE}`);
29
40
  url.searchParams.set("oauthError", "scope_not_granted");
30
41
  url.searchParams.set("oauthEmail", email);
31
42
  url.searchParams.set("missingServices", missingServices.join(","));
@@ -73,13 +84,12 @@ export const microsoftOAuthCallback = async (context, ctx) => {
73
84
  });
74
85
  // If Microsoft returned an error, pass it through to the frontend
75
86
  if (qs.error) {
76
- const errorCode = encodeURIComponent(qs.error);
77
- return redirect(`${webOrigin}/settings/accounts?oauthError=${errorCode}`);
87
+ return redirect(oauthRefusalUrl(webOrigin, qs.error));
78
88
  }
79
89
  const code = qs.code;
80
90
  const state = qs.state;
81
91
  if (!code || !state) {
82
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_params`);
92
+ return redirect(oauthRefusalUrl(webOrigin, "missing_params"));
83
93
  }
84
94
  const loaded = await loadMsOAuth(ctx.config.msOAuth).catch((err) => {
85
95
  logger.error({
@@ -92,7 +102,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
92
102
  return null;
93
103
  });
94
104
  if (loaded === null)
95
- return redirect(`${webOrigin}/settings/accounts?oauthError=config_error`);
105
+ return redirect(oauthRefusalUrl(webOrigin, "config_error"));
96
106
  const { config, credentials } = loaded;
97
107
  // Verify the HMAC-signed state
98
108
  // A bad/expired/forged state is a security-relevant signal (CSRF
@@ -108,7 +118,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
108
118
  return null;
109
119
  });
110
120
  if (statePayload === null)
111
- return redirect(`${webOrigin}/settings/accounts?oauthError=invalid_state`);
121
+ return redirect(oauthRefusalUrl(webOrigin, "invalid_state"));
112
122
  const { accountConfigId } = statePayload;
113
123
  const [firstService] = orderServices(statePayload.services);
114
124
  const serviceFor = (service) => createMailOAuthService(microsoftProviderConfig({
@@ -125,10 +135,10 @@ export const microsoftOAuthCallback = async (context, ctx) => {
125
135
  return null;
126
136
  });
127
137
  if (tokenSet === null)
128
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
138
+ return redirect(oauthRefusalUrl(webOrigin, "exchange_failed"));
129
139
  if (!tokenSet.refreshToken) {
130
140
  logger.error({ accountConfigId }, "MS OAuth token exchange returned no refresh_token");
131
- return redirect(`${webOrigin}/settings/accounts?oauthError=exchange_failed`);
141
+ return redirect(oauthRefusalUrl(webOrigin, "exchange_failed"));
132
142
  }
133
143
  // Extract the user's email from the ID token returned alongside the access
134
144
  // token when openid+email scopes are requested.
@@ -160,7 +170,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
160
170
  : undefined;
161
171
  if (!email) {
162
172
  logger.error({ accountConfigId }, "MS OAuth: could not extract email from token claims");
163
- return redirect(`${webOrigin}/settings/accounts?oauthError=missing_email`);
173
+ return redirect(oauthRefusalUrl(webOrigin, "missing_email"));
164
174
  }
165
175
  const { account, accountConfig, secrets } = ctx.client;
166
176
  // Reconnect when an active OAuth account already onboards this mailbox.
@@ -168,8 +178,10 @@ export const microsoftOAuthCallback = async (context, ctx) => {
168
178
  // the existing account (token refresh) rather than rejecting, because a
169
179
  // re-auth is the expected, idempotent path for OAuth.
170
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;
171
183
  const existing = findActiveDuplicateMailbox(existingAccounts, {
172
- imapHost: OUTLOOK_IMAP_HOST,
184
+ imapHost: imapServer.host,
173
185
  username: email,
174
186
  });
175
187
  const services = orderServices([
@@ -181,7 +193,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
181
193
  redemption.reason === "scope_not_granted")
182
194
  return redirect(scopeRefusalUrl(webOrigin, email, [redemption.service]));
183
195
  if (redemption.kind === "refused")
184
- return redirect(`${webOrigin}/settings/accounts?oauthError=${redemption.reason}`);
196
+ return redirect(oauthRefusalUrl(webOrigin, redemption.reason));
185
197
  const grantedScopes = [
186
198
  ...new Set([...tokenSet.grantedScopes, ...redemption.grantedScopes]),
187
199
  ];
@@ -228,15 +240,15 @@ export const microsoftOAuthCallback = async (context, ctx) => {
228
240
  grantedScopes,
229
241
  oauthRefreshTokenHash,
230
242
  oauthTokenUpdatedAt,
231
- imapHost: OUTLOOK_IMAP_HOST,
232
- imapPort: 993,
233
- imapTls: true,
234
- imapStartTls: false,
243
+ imapHost: imapServer.host,
244
+ imapPort: imapServer.port,
245
+ imapTls: imapServer.tls,
246
+ imapStartTls: imapServer.startTls,
235
247
  smtpEnabled: true,
236
- smtpHost: "smtp.office365.com",
237
- smtpPort: 587,
238
- smtpTls: false,
239
- smtpStartTls: true,
248
+ smtpHost: smtpServer.host,
249
+ smtpPort: smtpServer.port,
250
+ smtpTls: smtpServer.tls,
251
+ smtpStartTls: smtpServer.startTls,
240
252
  isActive: true,
241
253
  connectionState: ConnectionState.NotAuthenticated,
242
254
  });
@@ -247,7 +259,7 @@ export const microsoftOAuthCallback = async (context, ctx) => {
247
259
  if (services.includes(AccountService.Mail)) {
248
260
  await triggerAccountSyncSafe(ctx.client, accountId);
249
261
  }
250
- return redirect(`${webOrigin}/settings/accounts?connected=${encodeURIComponent(accountId)}`);
262
+ return redirect(`${webOrigin}${ACCOUNT_SETTINGS_ROUTE}?connected=${encodeURIComponent(accountId)}`);
251
263
  };
252
264
  /**
253
265
  * An instance with no Microsoft app answers the start of a sign-in with a
@@ -8,3 +8,7 @@ export interface OAuthState {
8
8
  export declare const STATE_TTL_MS: number;
9
9
  export declare function signState(payload: OAuthState, clientSecret: string): Promise<string>;
10
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;
@@ -53,3 +53,11 @@ export async function verifyState(state, clientSecret) {
53
53
  }
54
54
  return payload;
55
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.152",
3
+ "version": "0.0.153",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -67,6 +67,10 @@
67
67
  "types": "./dist/src/oauth-state.d.ts",
68
68
  "default": "./dist/src/oauth-state.js"
69
69
  },
70
+ "./msoauth": {
71
+ "types": "./dist/src/config/msoauth.d.ts",
72
+ "default": "./dist/src/config/msoauth.js"
73
+ },
70
74
  "./content-handler": {
71
75
  "types": "./dist/dev-server/content-handler.d.ts",
72
76
  "default": "./dist/dev-server/content-handler.js"