@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.
- package/dist/src/config/msoauth.d.ts +20 -2
- package/dist/src/config/msoauth.js +23 -0
- package/dist/src/handlers/account-oauth.d.ts +8 -11
- package/dist/src/handlers/account-oauth.js +49 -75
- package/dist/src/handlers/index.d.ts +1 -1
- package/dist/src/oauth-state.d.ts +14 -0
- package/dist/src/oauth-state.js +63 -0
- package/package.json +9 -1
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import type
|
|
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:
|
|
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 {
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
284
|
-
imapPort:
|
|
285
|
-
imapTls:
|
|
286
|
-
imapStartTls:
|
|
243
|
+
imapHost: imapServer.host,
|
|
244
|
+
imapPort: imapServer.port,
|
|
245
|
+
imapTls: imapServer.tls,
|
|
246
|
+
imapStartTls: imapServer.startTls,
|
|
287
247
|
smtpEnabled: true,
|
|
288
|
-
smtpHost:
|
|
289
|
-
smtpPort:
|
|
290
|
-
smtpTls:
|
|
291
|
-
smtpStartTls:
|
|
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}
|
|
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.
|
|
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"
|