@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
package/dist/src/api.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
|
|
2
1
|
import { type Document, OpenAPIBackend } from "openapi-backend";
|
|
3
2
|
export declare const OpenAPISpec: Document;
|
|
4
3
|
declare const api: OpenAPIBackend;
|
|
5
4
|
export { setLogger } from "@remit/logger";
|
|
6
5
|
export { api };
|
|
7
|
-
export declare const handleApiEvent: (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>;
|
package/dist/src/api.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
import spec from "@remit/api-openapi-spec/openapi.json" with { type: "json" };
|
|
2
2
|
import { redactCalendarFeedPath } from "@remit/calendar-service";
|
|
3
|
-
import { logger
|
|
3
|
+
import { logger } from "@remit/logger";
|
|
4
4
|
import { OpenAPIBackend, } from "openapi-backend";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { defaultErrorCode, handleError } from "./error.js";
|
|
8
|
-
import { handlers } from "./handlers/index.js";
|
|
9
|
-
import { authenticateSelfHostRequest } from "./jwt-auth.js";
|
|
10
|
-
import { normalizeRequest } from "./request.js";
|
|
11
|
-
import { runWithRequestContext } from "./request-context.js";
|
|
12
|
-
import { formatResponse, postResponseHandler } from "./response.js";
|
|
13
|
-
// Boot-time refusal: the local auth-bypass overrides must never be set in a
|
|
14
|
-
// deployed environment. Runs on module load so a leaked env var fails the
|
|
15
|
-
// process at startup rather than silently opening authentication at runtime.
|
|
16
|
-
assertLocalBypassNotInDeployedEnv();
|
|
5
|
+
import { defaultErrorCode } from "./error.js";
|
|
6
|
+
import { postResponseHandler } from "./response.js";
|
|
17
7
|
const isDocument = (value) => typeof value === "object" &&
|
|
18
8
|
value !== null &&
|
|
19
9
|
"openapi" in value &&
|
|
@@ -55,10 +45,10 @@ const validationDetails = (errors) => (errors ?? []).map((error) => ({
|
|
|
55
45
|
// holds the caller's headers and cookies — an Authorization bearer among them,
|
|
56
46
|
// and for a calendar feed the token in the path is itself the credential
|
|
57
47
|
// (issue #1065). The path is redacted the same way the request scope redacts it.
|
|
58
|
-
api.register("validationFail", (c
|
|
48
|
+
api.register("validationFail", (c) => {
|
|
59
49
|
logger.error({
|
|
60
50
|
errors: c.validation.errors,
|
|
61
|
-
path: redactCalendarFeedPath(
|
|
51
|
+
path: redactCalendarFeedPath(c.request.path),
|
|
62
52
|
operation: c.operation?.operationId,
|
|
63
53
|
}, "Validation failed");
|
|
64
54
|
return {
|
|
@@ -74,71 +64,10 @@ api.register("notFound", () => ({
|
|
|
74
64
|
statusCode: 404,
|
|
75
65
|
body: { code: defaultErrorCode(404), message: "Not found" },
|
|
76
66
|
}));
|
|
77
|
-
api.register("notImplemented",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
api.register(handlers);
|
|
67
|
+
api.register("notImplemented", () => ({
|
|
68
|
+
statusCode: 501,
|
|
69
|
+
body: { code: defaultErrorCode(501), message: "Not implemented" },
|
|
70
|
+
}));
|
|
82
71
|
api.init();
|
|
83
72
|
export { setLogger } from "@remit/logger";
|
|
84
73
|
export { api };
|
|
85
|
-
const readOriginHeader = (headers) => {
|
|
86
|
-
if (!headers)
|
|
87
|
-
return undefined;
|
|
88
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
89
|
-
if (key.toLowerCase() === "origin" && typeof value === "string") {
|
|
90
|
-
return value;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return undefined;
|
|
94
|
-
};
|
|
95
|
-
// A scope, not `logger.setBindings`: this process serves requests concurrently,
|
|
96
|
-
// and bindings on the shared logger belong to whichever request wrote them last,
|
|
97
|
-
// so a line gets attributed to the wrong request. The scope follows the request
|
|
98
|
-
// through its own async continuations and nothing else. It nests inside the one
|
|
99
|
-
// `withTelemetry` opens, which is where `requestId` comes from.
|
|
100
|
-
export const handleApiEvent = async (event, context) => {
|
|
101
|
-
// A calendar feed carries its credential in the path, so the raw path is a
|
|
102
|
-
// secret and cannot be a log field (issue #1065). Redacted once, here, where
|
|
103
|
-
// every line inside the request picks it up from the scope.
|
|
104
|
-
const path = redactCalendarFeedPath(event.path);
|
|
105
|
-
return withLogContext({
|
|
106
|
-
path,
|
|
107
|
-
method: event.httpMethod,
|
|
108
|
-
}, async () => {
|
|
109
|
-
logger.debug({ method: event.httpMethod, path }, "Request received");
|
|
110
|
-
const origin = readOriginHeader(event.headers);
|
|
111
|
-
const startedAt = Date.now();
|
|
112
|
-
// Capture the correlation id here: it is seeded by
|
|
113
|
-
// runWithRequestContext below (from context.awsRequestId), but that
|
|
114
|
-
// AsyncLocalStorage scope does not reliably survive the
|
|
115
|
-
// openapi-backend handleRequest call chain, so we read it from
|
|
116
|
-
// `context.awsRequestId` directly rather than via
|
|
117
|
-
// getRequestCorrelationId at log time.
|
|
118
|
-
const correlationId = context.awsRequestId;
|
|
119
|
-
const result = await runWithRequestContext({ origin, correlationId }, async () => {
|
|
120
|
-
if (usesBetterAuthJwt()) {
|
|
121
|
-
const denied = await authenticateSelfHostRequest(event);
|
|
122
|
-
if (denied)
|
|
123
|
-
return denied;
|
|
124
|
-
}
|
|
125
|
-
return api
|
|
126
|
-
.handleRequest(normalizeRequest(event), event, context)
|
|
127
|
-
.catch(handleError);
|
|
128
|
-
});
|
|
129
|
-
// One structured access line per request: method, path, status, and
|
|
130
|
-
// duration. The correlation id is inlined as a top-level field so a bug
|
|
131
|
-
// report quoting the id can grep logs by it directly, and emitted at
|
|
132
|
-
// info so it is on by default in production (the debug "Request
|
|
133
|
-
// received" above already filters out under normal log levels).
|
|
134
|
-
// biome-ignore lint/plugin/no-logger-info: one access line per request is an audit-grade signal (issue #1054)
|
|
135
|
-
logger.info({
|
|
136
|
-
method: event.httpMethod,
|
|
137
|
-
path,
|
|
138
|
-
statusCode: result.statusCode,
|
|
139
|
-
durationMs: Date.now() - startedAt,
|
|
140
|
-
correlationId,
|
|
141
|
-
}, "Request completed");
|
|
142
|
-
return result;
|
|
143
|
-
});
|
|
144
|
-
};
|
package/dist/src/auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
2
|
+
import type { HandlerIdentity } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* The LOCAL_ACCOUNT_CONFIG_ID / LOCAL_COGNITO_SUB overrides bypass authentication
|
|
4
5
|
* for local dev and fixtures: with them set, an unauthenticated request is served
|
|
@@ -6,25 +7,18 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
|
6
7
|
* start (throw at module load) if either is present outside a local NODE_ENV — the
|
|
7
8
|
* same `development`/`test` signal the data layer already uses to decide local vs
|
|
8
9
|
* deployed. Fail loud rather than silently ignore, so a leaked env var is caught at
|
|
9
|
-
* boot, not exploited at runtime.
|
|
10
|
+
* boot, not exploited at runtime. In a local NODE_ENV the bypass is read here
|
|
11
|
+
* once, so a bypass set without LOCAL_EMAIL also fails at boot.
|
|
10
12
|
*/
|
|
11
|
-
export declare const assertLocalBypassNotInDeployedEnv: (env
|
|
13
|
+
export declare const assertLocalBypassNotInDeployedEnv: (env: NodeJS.ProcessEnv) => void;
|
|
14
|
+
export declare const userFromClaims: (sub: string, email: string | undefined) => HandlerIdentity;
|
|
15
|
+
export declare const localIdentityFromEnv: (env: NodeJS.ProcessEnv) => HandlerIdentity;
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `LOCAL_ACCOUNT_CONFIG_ID`)
|
|
17
|
+
* The caller of an API Gateway event. Production reads the Cognito `sub` and
|
|
18
|
+
* `email` claims (verified by the authorizer, or by `jwt-auth.ts` on
|
|
19
|
+
* self-host) and derives the accountConfigId via UUIDv5. Local development
|
|
20
|
+
* honours `LOCAL_ACCOUNT_CONFIG_ID` (a pinned seeded config) and
|
|
21
|
+
* `LOCAL_COGNITO_SUB` (a pinned sub, derived like production), read once at
|
|
22
|
+
* boot into `localIdentity`.
|
|
17
23
|
*/
|
|
18
|
-
export declare const
|
|
19
|
-
/**
|
|
20
|
-
* Extract the caller's accountConfigId from an API Gateway event.
|
|
21
|
-
*
|
|
22
|
-
* Production: reads the Cognito `sub` claim (verified by the API Gateway
|
|
23
|
-
* Cognito authorizer) and derives the accountConfigId via UUIDv5.
|
|
24
|
-
*
|
|
25
|
-
* Local development: honours two environment overrides so the dev server and
|
|
26
|
-
* playwright fixtures can run without a real Cognito token:
|
|
27
|
-
* - `LOCAL_ACCOUNT_CONFIG_ID` — pin a specific seeded accountConfigId
|
|
28
|
-
* - `LOCAL_COGNITO_SUB` — pin a Cognito `sub` value; derived like production
|
|
29
|
-
*/
|
|
30
|
-
export declare const getAccountConfigIdFromEvent: (event: APIGatewayProxyEvent) => string;
|
|
24
|
+
export declare const getIdentityFromEvent: (event: APIGatewayProxyEvent, localIdentity: HandlerIdentity) => HandlerIdentity;
|
package/dist/src/auth.js
CHANGED
|
@@ -11,11 +11,14 @@ const isLocalEnv = (env) => env.NODE_ENV === "development" || env.NODE_ENV === "
|
|
|
11
11
|
* start (throw at module load) if either is present outside a local NODE_ENV — the
|
|
12
12
|
* same `development`/`test` signal the data layer already uses to decide local vs
|
|
13
13
|
* deployed. Fail loud rather than silently ignore, so a leaked env var is caught at
|
|
14
|
-
* boot, not exploited at runtime.
|
|
14
|
+
* boot, not exploited at runtime. In a local NODE_ENV the bypass is read here
|
|
15
|
+
* once, so a bypass set without LOCAL_EMAIL also fails at boot.
|
|
15
16
|
*/
|
|
16
|
-
export const assertLocalBypassNotInDeployedEnv = (env
|
|
17
|
-
if (isLocalEnv(env))
|
|
17
|
+
export const assertLocalBypassNotInDeployedEnv = (env) => {
|
|
18
|
+
if (isLocalEnv(env)) {
|
|
19
|
+
localIdentityFromEnv(env);
|
|
18
20
|
return;
|
|
21
|
+
}
|
|
19
22
|
const leaked = LOCAL_BYPASS_VARS.filter((name) => {
|
|
20
23
|
const value = env[name];
|
|
21
24
|
return typeof value === "string" && value.length > 0;
|
|
@@ -24,50 +27,55 @@ export const assertLocalBypassNotInDeployedEnv = (env = process.env) => {
|
|
|
24
27
|
return;
|
|
25
28
|
throw new Error(`Refusing to start: local auth bypass ${leaked.join(", ")} is set while NODE_ENV=${env.NODE_ENV ?? "(unset)"}. These overrides bypass authentication and must never be present in a deployed environment.`);
|
|
26
29
|
};
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
return
|
|
31
|
-
const sub = claims.sub;
|
|
32
|
-
if (typeof sub === "string" && sub.length > 0)
|
|
33
|
-
return sub;
|
|
30
|
+
const readClaim = (event, name) => {
|
|
31
|
+
const value = event.requestContext?.authorizer?.claims?.[name];
|
|
32
|
+
if (typeof value === "string" && value.length > 0)
|
|
33
|
+
return value;
|
|
34
34
|
return undefined;
|
|
35
35
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
const ANONYMOUS = { kind: "anonymous" };
|
|
37
|
+
export const userFromClaims = (sub, email) => {
|
|
38
|
+
if (!email)
|
|
39
|
+
return ANONYMOUS;
|
|
40
|
+
return {
|
|
41
|
+
kind: "user",
|
|
42
|
+
accountConfigId: deriveAccountConfigId(sub),
|
|
43
|
+
sub,
|
|
44
|
+
email,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const pinnedLocalUser = (env) => {
|
|
48
|
+
const localAccountConfigId = env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
49
|
+
if (localAccountConfigId) {
|
|
50
|
+
return { accountConfigId: localAccountConfigId, sub: localAccountConfigId };
|
|
51
|
+
}
|
|
52
|
+
const localSub = env.LOCAL_COGNITO_SUB;
|
|
53
|
+
if (localSub) {
|
|
54
|
+
return { accountConfigId: deriveAccountConfigId(localSub), sub: localSub };
|
|
55
|
+
}
|
|
49
56
|
return undefined;
|
|
50
57
|
};
|
|
58
|
+
export const localIdentityFromEnv = (env) => {
|
|
59
|
+
const pinned = pinnedLocalUser(env);
|
|
60
|
+
if (!pinned)
|
|
61
|
+
return ANONYMOUS;
|
|
62
|
+
const email = env.LOCAL_EMAIL;
|
|
63
|
+
if (!email) {
|
|
64
|
+
throw new Error("Refusing to start: the local auth bypass (LOCAL_ACCOUNT_CONFIG_ID or LOCAL_COGNITO_SUB) is set without LOCAL_EMAIL. The bypass serves a signed-in user, and every user carries an email; set LOCAL_EMAIL to the address that user signs in with.");
|
|
65
|
+
}
|
|
66
|
+
return { kind: "user", ...pinned, email };
|
|
67
|
+
};
|
|
51
68
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* playwright fixtures can run without a real Cognito token:
|
|
59
|
-
* - `LOCAL_ACCOUNT_CONFIG_ID` — pin a specific seeded accountConfigId
|
|
60
|
-
* - `LOCAL_COGNITO_SUB` — pin a Cognito `sub` value; derived like production
|
|
69
|
+
* The caller of an API Gateway event. Production reads the Cognito `sub` and
|
|
70
|
+
* `email` claims (verified by the authorizer, or by `jwt-auth.ts` on
|
|
71
|
+
* self-host) and derives the accountConfigId via UUIDv5. Local development
|
|
72
|
+
* honours `LOCAL_ACCOUNT_CONFIG_ID` (a pinned seeded config) and
|
|
73
|
+
* `LOCAL_COGNITO_SUB` (a pinned sub, derived like production), read once at
|
|
74
|
+
* boot into `localIdentity`.
|
|
61
75
|
*/
|
|
62
|
-
export const
|
|
63
|
-
const sub =
|
|
64
|
-
if (sub)
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
if (localAccountConfigId)
|
|
68
|
-
return localAccountConfigId;
|
|
69
|
-
const localSub = process.env.LOCAL_COGNITO_SUB;
|
|
70
|
-
if (localSub)
|
|
71
|
-
return deriveAccountConfigId(localSub);
|
|
72
|
-
throw new Error("Missing accountConfigId: no Cognito `sub` claim on the request, and neither LOCAL_ACCOUNT_CONFIG_ID nor LOCAL_COGNITO_SUB env var is set");
|
|
76
|
+
export const getIdentityFromEvent = (event, localIdentity) => {
|
|
77
|
+
const sub = readClaim(event, "sub");
|
|
78
|
+
if (!sub)
|
|
79
|
+
return localIdentity;
|
|
80
|
+
return userFromClaims(sub, readClaim(event, "email"));
|
|
73
81
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MsOAuthConfig } from "./msoauth.js";
|
|
2
|
+
export type AuthConfig = {
|
|
3
|
+
mode: "BetterAuthJwt";
|
|
4
|
+
contentSigningSecret: string;
|
|
5
|
+
} | {
|
|
6
|
+
mode: "GatewayAuthorizer";
|
|
7
|
+
};
|
|
8
|
+
export type ResponseValidation = "Log" | "Skip";
|
|
9
|
+
export type SelfUpdate = "Enabled" | "Disabled";
|
|
10
|
+
export interface ApiConfig {
|
|
11
|
+
auth: AuthConfig;
|
|
12
|
+
corsAllowedOrigins: readonly string[];
|
|
13
|
+
publicOrigin: string;
|
|
14
|
+
contentDeliveryDomain: string;
|
|
15
|
+
msOAuth: MsOAuthConfig;
|
|
16
|
+
selfUpdate: SelfUpdate;
|
|
17
|
+
updateControlDir: string;
|
|
18
|
+
commit: string;
|
|
19
|
+
responseValidation: ResponseValidation;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { msOAuthCredentialsFromSecret } from "./msoauth-secrets-manager.js";
|
|
2
|
+
const DEFAULT_UPDATE_CONTROL_DIR = "/data/control";
|
|
3
|
+
const optional = (env, key) => env[key] || undefined;
|
|
4
|
+
const required = (env, key, reason) => {
|
|
5
|
+
const value = optional(env, key);
|
|
6
|
+
if (!value)
|
|
7
|
+
throw new Error(`Refusing to start: ${key} is not set. ${reason}`);
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
const commaList = (raw) => (raw ?? "")
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((origin) => origin.trim())
|
|
13
|
+
.filter((origin) => origin.length > 0);
|
|
14
|
+
const authFromEnv = (env) => env.DATA_BACKEND === "sqlite"
|
|
15
|
+
? {
|
|
16
|
+
mode: "BetterAuthJwt",
|
|
17
|
+
contentSigningSecret: required(env, "BETTER_AUTH_SECRET", "The self-host SQL backend signs content URLs with it."),
|
|
18
|
+
}
|
|
19
|
+
: { mode: "GatewayAuthorizer" };
|
|
20
|
+
const contentDeliveryDomainFromEnv = (env, auth, publicOrigin) => {
|
|
21
|
+
if (auth.mode === "BetterAuthJwt") {
|
|
22
|
+
return optional(env, "CONTENT_DELIVERY_DOMAIN") ?? publicOrigin;
|
|
23
|
+
}
|
|
24
|
+
return required(env, "CONTENT_DELIVERY_DOMAIN", "BodyPartResponse.contentUrl is built from the CloudFront distribution domain (#299); wire it via the API Lambda env (see infra/bin/infra.ts).");
|
|
25
|
+
};
|
|
26
|
+
const msOAuthCredentialsFromEnv = (env) => {
|
|
27
|
+
const clientId = optional(env, "MSOAUTH_CLIENT_ID");
|
|
28
|
+
const clientSecret = optional(env, "MSOAUTH_CLIENT_SECRET");
|
|
29
|
+
if (clientId && clientSecret) {
|
|
30
|
+
return async () => ({ clientId, clientSecret });
|
|
31
|
+
}
|
|
32
|
+
const secretArn = optional(env, "MSOAUTH_SECRET_ARN");
|
|
33
|
+
if (secretArn)
|
|
34
|
+
return msOAuthCredentialsFromSecret(secretArn);
|
|
35
|
+
return undefined;
|
|
36
|
+
};
|
|
37
|
+
const msOAuthFromEnv = (env) => {
|
|
38
|
+
const credentials = msOAuthCredentialsFromEnv(env);
|
|
39
|
+
if (!credentials)
|
|
40
|
+
return { mode: "Disabled" };
|
|
41
|
+
const tokenEndpoint = optional(env, "MSOAUTH_TOKEN_ENDPOINT");
|
|
42
|
+
return {
|
|
43
|
+
mode: "Enabled",
|
|
44
|
+
redirectUri: required(env, "MSOAUTH_REDIRECT_URI", "It is derived from PUBLIC_ORIGIN — set PUBLIC_ORIGIN in the deploy env file (deploy/vps/.env) and restart, or set MSOAUTH_REDIRECT_URI directly."),
|
|
45
|
+
overrides: tokenEndpoint ? { tokenEndpoint } : {},
|
|
46
|
+
credentials,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export const configFromEnv = (env) => {
|
|
50
|
+
const publicOrigin = required(env, "PUBLIC_ORIGIN", "It is the origin the web client is served from.");
|
|
51
|
+
const auth = authFromEnv(env);
|
|
52
|
+
return {
|
|
53
|
+
auth,
|
|
54
|
+
corsAllowedOrigins: commaList(env.CORS_ALLOWED_ORIGINS),
|
|
55
|
+
publicOrigin,
|
|
56
|
+
contentDeliveryDomain: contentDeliveryDomainFromEnv(env, auth, publicOrigin),
|
|
57
|
+
msOAuth: msOAuthFromEnv(env),
|
|
58
|
+
selfUpdate: optional(env, "REMIT_UPDATE_MANIFEST_URL")
|
|
59
|
+
? "Enabled"
|
|
60
|
+
: "Disabled",
|
|
61
|
+
updateControlDir: optional(env, "REMIT_UPDATE_CONTROL_DIR") ?? DEFAULT_UPDATE_CONTROL_DIR,
|
|
62
|
+
commit: optional(env, "REMIT_WORKER_SHA") ?? optional(env, "GITHUB_SHA") ?? "dev",
|
|
63
|
+
responseValidation: env.STAGE_NAME === "dev" ? "Log" : "Skip",
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { GetSecretValueCommand, SecretsManagerClient, } from "@aws-sdk/client-secrets-manager";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const MsOAuthSecret = z.object({
|
|
4
|
+
clientId: z.string().min(1),
|
|
5
|
+
clientSecret: z.string().min(1),
|
|
6
|
+
});
|
|
7
|
+
let client = null;
|
|
8
|
+
const secretsManager = () => {
|
|
9
|
+
if (!client)
|
|
10
|
+
client = new SecretsManagerClient({});
|
|
11
|
+
return client;
|
|
12
|
+
};
|
|
13
|
+
const parseSecret = (raw) => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export const msOAuthCredentialsFromSecret = (secretArn) => async () => {
|
|
22
|
+
const result = await secretsManager().send(new GetSecretValueCommand({ SecretId: secretArn }));
|
|
23
|
+
const parsed = MsOAuthSecret.safeParse(parseSecret(result.SecretString ?? ""));
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
throw new Error("The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN does not hold a JSON object with a non-empty clientId and clientSecret");
|
|
26
|
+
}
|
|
27
|
+
return parsed.data;
|
|
28
|
+
};
|
|
@@ -1,30 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* (deploy/vps/docker-compose.sqlite.yml) and must match the redirect URI
|
|
6
|
-
* registered on the Entra app registration.
|
|
7
|
-
*
|
|
8
|
-
* Credentials come either from `MSOAUTH_CLIENT_ID` / `MSOAUTH_CLIENT_SECRET`
|
|
9
|
-
* in the deploy env file, or from Secrets Manager via `MSOAUTH_SECRET_ARN`.
|
|
10
|
-
*
|
|
11
|
-
* The Microsoft authorization and token endpoints are fixed in
|
|
12
|
-
* `@remit/mail-oauth-service`; no authority URL is read from the environment.
|
|
13
|
-
*/
|
|
14
|
-
export interface MsOAuthConfig {
|
|
15
|
-
/** Secrets Manager ARN — an alternative to the client id/secret pair. */
|
|
16
|
-
readonly secretArn: string | undefined;
|
|
17
|
-
/** OAuth redirect URI registered in the Entra app. */
|
|
18
|
-
readonly redirectUri: string;
|
|
19
|
-
/** Client ID — set directly to bypass Secrets Manager. */
|
|
20
|
-
readonly clientId: string | undefined;
|
|
21
|
-
/** Client secret — set directly to bypass Secrets Manager. */
|
|
22
|
-
readonly clientSecret: string | undefined;
|
|
23
|
-
/** Token endpoint override — used for local stubbing (bypasses default OIDC discovery). */
|
|
24
|
-
readonly tokenEndpoint: string | undefined;
|
|
1
|
+
import type { OAuthProviderConfig } from "@remit/mail-oauth-service";
|
|
2
|
+
export interface MsOAuthCredentials {
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
25
5
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
6
|
+
export interface MsOAuthEnabled {
|
|
7
|
+
mode: "Enabled";
|
|
8
|
+
redirectUri: string;
|
|
9
|
+
overrides: Partial<Pick<OAuthProviderConfig, "tokenEndpoint">>;
|
|
10
|
+
credentials: () => Promise<MsOAuthCredentials>;
|
|
11
|
+
}
|
|
12
|
+
export type MsOAuthConfig = {
|
|
13
|
+
mode: "Disabled";
|
|
14
|
+
} | MsOAuthEnabled;
|
|
15
|
+
export interface LoadedMsOAuth {
|
|
16
|
+
config: MsOAuthEnabled;
|
|
17
|
+
credentials: MsOAuthCredentials;
|
|
18
|
+
}
|
|
19
|
+
export declare const loadMsOAuth: (config: MsOAuthConfig) => Promise<LoadedMsOAuth>;
|
|
@@ -1,30 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* `MSOAUTH_REDIRECT_URI` is derived from `PUBLIC_ORIGIN` by the compose file
|
|
5
|
-
* (deploy/vps/docker-compose.sqlite.yml) and must match the redirect URI
|
|
6
|
-
* registered on the Entra app registration.
|
|
7
|
-
*
|
|
8
|
-
* Credentials come either from `MSOAUTH_CLIENT_ID` / `MSOAUTH_CLIENT_SECRET`
|
|
9
|
-
* in the deploy env file, or from Secrets Manager via `MSOAUTH_SECRET_ARN`.
|
|
10
|
-
*
|
|
11
|
-
* The Microsoft authorization and token endpoints are fixed in
|
|
12
|
-
* `@remit/mail-oauth-service`; no authority URL is read from the environment.
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* Read Microsoft OAuth configuration from the environment.
|
|
16
|
-
* Throws when `MSOAUTH_REDIRECT_URI` is absent.
|
|
17
|
-
*/
|
|
18
|
-
export const getMsOAuthConfig = () => {
|
|
19
|
-
const redirectUri = process.env.MSOAUTH_REDIRECT_URI;
|
|
20
|
-
if (!redirectUri) {
|
|
21
|
-
throw new Error("MSOAUTH_REDIRECT_URI is not set. It is derived from PUBLIC_ORIGIN — set PUBLIC_ORIGIN in the deploy env file (deploy/vps/.env) and restart, or set MSOAUTH_REDIRECT_URI directly.");
|
|
1
|
+
export const loadMsOAuth = async (config) => {
|
|
2
|
+
if (config.mode === "Disabled") {
|
|
3
|
+
throw new Error("No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN");
|
|
22
4
|
}
|
|
23
|
-
return {
|
|
24
|
-
secretArn: process.env.MSOAUTH_SECRET_ARN,
|
|
25
|
-
redirectUri,
|
|
26
|
-
clientId: process.env.MSOAUTH_CLIENT_ID,
|
|
27
|
-
clientSecret: process.env.MSOAUTH_CLIENT_SECRET,
|
|
28
|
-
tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT,
|
|
29
|
-
};
|
|
5
|
+
return { config, credentials: await config.credentials() };
|
|
30
6
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SignedPathFailure } from "@remit/storage-service/signed-path";
|
|
2
|
+
import type { ApiConfig } from "../config/api-config.js";
|
|
2
3
|
/**
|
|
3
4
|
* Default validity window for a signed content URL. Long enough to cover a
|
|
4
5
|
* viewing session (react-query caches describeMessage for 30 min and inline
|
|
@@ -21,11 +22,9 @@ export declare const createContentSigner: (masterSecret: string, ttlSeconds?: nu
|
|
|
21
22
|
* signing does not apply. Signing is enforced on the self-host SQL backend,
|
|
22
23
|
* which serves `/content/*` straight from the backend container; on AWS the
|
|
23
24
|
* Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
|
|
24
|
-
* CloudFront/S3 behaviour is unchanged.
|
|
25
|
-
* master secret is missing, so a misconfigured deploy fails loud rather than
|
|
26
|
-
* shipping unsigned (unauthenticated) content URLs.
|
|
25
|
+
* CloudFront/S3 behaviour is unchanged.
|
|
27
26
|
*/
|
|
28
|
-
export declare const getContentSigner: () => ContentSigner | undefined;
|
|
27
|
+
export declare const getContentSigner: (config: ApiConfig) => ContentSigner | undefined;
|
|
29
28
|
export type ContentSignatureFailure = SignedPathFailure;
|
|
30
29
|
export type ContentSignatureResult = {
|
|
31
30
|
valid: true;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { signStoragePath, verifyStoragePath, } from "@remit/storage-service/signed-path";
|
|
2
|
-
import { usesBetterAuthJwt } from "../data-backend.js";
|
|
3
2
|
/**
|
|
4
3
|
* Signed-URL scheme for `/content/*` on the self-host stack.
|
|
5
4
|
*
|
|
@@ -48,19 +47,11 @@ export const createContentSigner = (masterSecret, ttlSeconds = CONTENT_URL_TTL_S
|
|
|
48
47
|
* signing does not apply. Signing is enforced on the self-host SQL backend,
|
|
49
48
|
* which serves `/content/*` straight from the backend container; on AWS the
|
|
50
49
|
* Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
|
|
51
|
-
* CloudFront/S3 behaviour is unchanged.
|
|
52
|
-
* master secret is missing, so a misconfigured deploy fails loud rather than
|
|
53
|
-
* shipping unsigned (unauthenticated) content URLs.
|
|
50
|
+
* CloudFront/S3 behaviour is unchanged.
|
|
54
51
|
*/
|
|
55
|
-
export const getContentSigner = () =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const secret = process.env.BETTER_AUTH_SECRET;
|
|
59
|
-
if (!secret || secret.length === 0) {
|
|
60
|
-
throw new Error("the self-host SQL backend requires BETTER_AUTH_SECRET to sign content URLs");
|
|
61
|
-
}
|
|
62
|
-
return createContentSigner(secret);
|
|
63
|
-
};
|
|
52
|
+
export const getContentSigner = (config) => config.auth.mode === "BetterAuthJwt"
|
|
53
|
+
? createContentSigner(config.auth.contentSigningSecret)
|
|
54
|
+
: undefined;
|
|
64
55
|
/**
|
|
65
56
|
* Verify a signed content request. Recomputes the HMAC over the requested path
|
|
66
57
|
* and the supplied expiry and constant-time compares it against the presented
|
|
@@ -31,16 +31,3 @@ export interface BuildStorageContentUrlInput {
|
|
|
31
31
|
}
|
|
32
32
|
export declare const buildStorageContentUrl: (input: BuildStorageContentUrlInput) => string;
|
|
33
33
|
export declare const buildContentUrl: (input: BuildContentUrlInput) => string;
|
|
34
|
-
/**
|
|
35
|
-
* Read the CloudFront distribution domain from the environment. The CDK
|
|
36
|
-
* wires `CONTENT_DELIVERY_DOMAIN` onto the API Lambda from the
|
|
37
|
-
* ContentDelivery stack's published SSM parameter (`/{stage}/Remit/frontendUrl`).
|
|
38
|
-
*
|
|
39
|
-
* Throws when the env var is missing or empty. `BodyPartResponse.contentUrl`
|
|
40
|
-
* is required + URL-formatted in the API contract, so silently emitting a
|
|
41
|
-
* placeholder string would ship a contract lie to clients. Failing loud here
|
|
42
|
-
* surfaces the missing wire-up as a 500 the deploy team can act on (#299).
|
|
43
|
-
* Production synth hard-codes a syntactically-valid placeholder URL via
|
|
44
|
-
* `infra/bin/infra.ts` so this only fires when the env is actually unset.
|
|
45
|
-
*/
|
|
46
|
-
export declare const getContentDeliveryDomain: () => string;
|
|
@@ -25,22 +25,3 @@ export const buildContentUrl = (input) => {
|
|
|
25
25
|
sign,
|
|
26
26
|
});
|
|
27
27
|
};
|
|
28
|
-
/**
|
|
29
|
-
* Read the CloudFront distribution domain from the environment. The CDK
|
|
30
|
-
* wires `CONTENT_DELIVERY_DOMAIN` onto the API Lambda from the
|
|
31
|
-
* ContentDelivery stack's published SSM parameter (`/{stage}/Remit/frontendUrl`).
|
|
32
|
-
*
|
|
33
|
-
* Throws when the env var is missing or empty. `BodyPartResponse.contentUrl`
|
|
34
|
-
* is required + URL-formatted in the API contract, so silently emitting a
|
|
35
|
-
* placeholder string would ship a contract lie to clients. Failing loud here
|
|
36
|
-
* surfaces the missing wire-up as a 500 the deploy team can act on (#299).
|
|
37
|
-
* Production synth hard-codes a syntactically-valid placeholder URL via
|
|
38
|
-
* `infra/bin/infra.ts` so this only fires when the env is actually unset.
|
|
39
|
-
*/
|
|
40
|
-
export const getContentDeliveryDomain = () => {
|
|
41
|
-
const value = process.env.CONTENT_DELIVERY_DOMAIN;
|
|
42
|
-
if (!value || value.length === 0) {
|
|
43
|
-
throw new Error("CONTENT_DELIVERY_DOMAIN is not set; BodyPartResponse.contentUrl cannot be built. Wire the CloudFront distribution domain via the API Lambda env (see infra/bin/infra.ts).");
|
|
44
|
-
}
|
|
45
|
-
return value;
|
|
46
|
-
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccountService as AccountServiceName } from "@remit/api-openapi-types";
|
|
2
|
-
import type {
|
|
2
|
+
import type { Context } from "openapi-backend";
|
|
3
|
+
import type { HandlerContext, PublicOperationHandler } from "../types.js";
|
|
3
4
|
export interface OAuthState {
|
|
4
5
|
accountConfigId: string;
|
|
5
6
|
nonce: string;
|
|
@@ -10,5 +11,10 @@ export declare const STATE_TTL_MS: number;
|
|
|
10
11
|
export declare function signState(payload: OAuthState, clientSecret: string): Promise<string>;
|
|
11
12
|
export declare function verifyState(state: string, clientSecret: string): Promise<OAuthState>;
|
|
12
13
|
export declare function parseJwtClaims(token: string): Promise<Record<string, unknown> | null>;
|
|
13
|
-
export declare function getWebOrigin(): string;
|
|
14
|
-
export declare const
|
|
14
|
+
export declare function getWebOrigin(origins: readonly string[]): string;
|
|
15
|
+
export declare const microsoftOAuthCallback: PublicOperationHandler<"sync">;
|
|
16
|
+
export declare const MicrosoftOAuthOperations: {
|
|
17
|
+
MicrosoftOAuthOperations_microsoftOAuthStart: (context: Context, ctx: HandlerContext) => Promise<{
|
|
18
|
+
authorizationUrl: string;
|
|
19
|
+
}>;
|
|
20
|
+
};
|