@open-mercato/core 0.8.1-develop.7449.1.eecc56d25f → 0.8.1-develop.7451.1.83d4762b57
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/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
- package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
- package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
- package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
- package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
- package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
- package/src/modules/communication_channels/i18n/de.json +1 -0
- package/src/modules/communication_channels/i18n/en.json +1 -0
- package/src/modules/communication_channels/i18n/es.json +1 -0
- package/src/modules/communication_channels/i18n/ko.json +1 -0
- package/src/modules/communication_channels/i18n/pl.json +1 -0
- package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
- package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
|
@@ -4,6 +4,8 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
6
6
|
import { toAbsoluteUrl } from "@open-mercato/shared/lib/url";
|
|
7
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
8
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
7
9
|
import { getChannelAdapter } from "../../../../../lib/adapter-registry-singleton.js";
|
|
8
10
|
import { resolveOAuthClientCredentials } from "../../../../../lib/oauth-client-config.js";
|
|
9
11
|
import {
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
normalizeOAuthReturnUrl,
|
|
16
18
|
OAuthStateError
|
|
17
19
|
} from "../../../../../lib/oauth-state.js";
|
|
20
|
+
const logger = createLogger("communication_channels").child({ component: "oauth-initiate" });
|
|
18
21
|
const metadata = {
|
|
19
22
|
path: "/communication_channels/oauth/[provider]/initiate",
|
|
20
23
|
POST: {
|
|
@@ -34,6 +37,20 @@ const initiateBodySchema = z.object({
|
|
|
34
37
|
function defaultRedirectUri(req, providerKey) {
|
|
35
38
|
return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`);
|
|
36
39
|
}
|
|
40
|
+
function oauthStateErrorResponse(err) {
|
|
41
|
+
if (err instanceof OAuthStateError) {
|
|
42
|
+
if (err.code === "missing_secret") {
|
|
43
|
+
logger.error("OAuth state secret is not configured", { err });
|
|
44
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
45
|
+
module: "communication_channels",
|
|
46
|
+
code: "communication_channels.missing_secret"
|
|
47
|
+
});
|
|
48
|
+
return NextResponse.json({ error: "OAuth is not available", code: err.code }, { status: 500 });
|
|
49
|
+
}
|
|
50
|
+
return NextResponse.json({ error: err.message, code: err.code }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
37
54
|
async function POST(req, context) {
|
|
38
55
|
const { provider } = await context.params;
|
|
39
56
|
if (!/^[a-z0-9_-]+$/i.test(provider)) {
|
|
@@ -88,13 +105,20 @@ async function POST(req, context) {
|
|
|
88
105
|
);
|
|
89
106
|
}
|
|
90
107
|
const redirectUri = defaultRedirectUri(req, provider);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
let stateEnvelope;
|
|
109
|
+
try {
|
|
110
|
+
stateEnvelope = createOAuthState({
|
|
111
|
+
userId: auth.sub,
|
|
112
|
+
tenantId: auth.tenantId,
|
|
113
|
+
organizationId: auth.orgId ?? null,
|
|
114
|
+
providerKey: provider,
|
|
115
|
+
returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const response2 = oauthStateErrorResponse(err);
|
|
119
|
+
if (response2) return response2;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
98
122
|
let result;
|
|
99
123
|
try {
|
|
100
124
|
result = await adapter.buildOAuthAuthorizeUrl({
|
|
@@ -115,10 +139,21 @@ async function POST(req, context) {
|
|
|
115
139
|
const message = err instanceof Error ? err.message : "Failed to build OAuth authorize URL";
|
|
116
140
|
return NextResponse.json({ error: message }, { status: 502 });
|
|
117
141
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
let finalCookie;
|
|
143
|
+
if (result.extra) {
|
|
144
|
+
try {
|
|
145
|
+
finalCookie = (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
|
|
146
|
+
...stateEnvelope.payload,
|
|
147
|
+
extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const response2 = oauthStateErrorResponse(err);
|
|
151
|
+
if (response2) return response2;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
finalCookie = stateEnvelope.cookie;
|
|
156
|
+
}
|
|
122
157
|
const response = NextResponse.json({ authorizeUrl: result.authorizeUrl });
|
|
123
158
|
response.cookies.set({
|
|
124
159
|
name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,
|
|
@@ -142,6 +177,7 @@ const openApi = {
|
|
|
142
177
|
{ status: 400, description: "Invalid provider or unsupported (no OAuth)" },
|
|
143
178
|
{ status: 401, description: "Unauthorized" },
|
|
144
179
|
{ status: 422, description: "Invalid request body" },
|
|
180
|
+
{ status: 500, description: "OAuth state secret missing or state encryption failed" },
|
|
145
181
|
{ status: 502, description: "Adapter failed to build authorize URL" }
|
|
146
182
|
]
|
|
147
183
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/communication_channels/api/post/oauth/%5Bprovider%5D/initiate/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,qCAAqC;AAC9C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nconst logger = createLogger('communication_channels').child({ component: 'oauth-initiate' })\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nfunction oauthStateErrorResponse(err: unknown): Response | null {\n if (err instanceof OAuthStateError) {\n if (err.code === 'missing_secret') {\n logger.error('OAuth state secret is not configured', { err })\n getTelemetryRuntime()?.reportError(err, {\n module: 'communication_channels',\n code: 'communication_channels.missing_secret',\n })\n return NextResponse.json({ error: 'OAuth is not available', code: err.code }, { status: 500 })\n }\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n return null\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n let stateEnvelope: ReturnType<typeof createOAuthState>\n try {\n stateEnvelope = createOAuthState({\n userId: auth.sub as string,\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n providerKey: provider,\n returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n\n let result\n try {\n result = await adapter.buildOAuthAuthorizeUrl({\n state: stateEnvelope.stateParam,\n nonce: stateEnvelope.payload.nonce,\n redirectUri,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId:\n (auth as { orgId?: string | null }).orgId ?? (auth.tenantId as string),\n },\n loginHint: body.loginHint,\n })\n } catch (err) {\n if (err instanceof OAuthStateError) {\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n const message = err instanceof Error ? err.message : 'Failed to build OAuth authorize URL'\n return NextResponse.json({ error: message }, { status: 502 })\n }\n\n // If the adapter packed extras (PKCE verifier, scopes), bake them into the\n // state cookie now so the callback handler can pass them to exchangeOAuthCode.\n let finalCookie: string\n if (result.extra) {\n try {\n finalCookie = (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n } else {\n finalCookie = stateEnvelope.cookie\n }\n\n const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })\n response.cookies.set({\n name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n value: finalCookie,\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: Math.floor(COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS / 1000),\n })\n return response\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Start a per-user channel OAuth flow',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Authorize URL + state cookie set' },\n { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },\n { status: 401, description: 'Unauthorized' },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'OAuth state secret missing or state encryption failed' },\n { status: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,qCAAqC;AAC9C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,SAAS,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,iBAAiB,CAAC;AAEpF,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA,IACJ,aAAa;AAAA,IACb,iBAAiB,CAAC,6CAA6C;AAAA,EACjE;AACF;AAEA,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,aAAa,EAAE,QAAQ,OAAO,EAAE,SAAS;AAAA;AAAA,EAEzC,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,IAAI,EAAE,OAAO,sBAAsB;AAAA,IAClE,SAAS;AAAA,EACX,CAAC,EAAE,SAAS;AAAA;AAAA,EAEZ,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS;AACzC,CAAC;AAiBD,SAAS,mBAAmB,KAAc,aAA6B;AAIrE,SAAO,cAAc,KAAK,qCAAqC,WAAW,WAAW;AACvF;AAEA,SAAS,wBAAwB,KAA+B;AAC9D,MAAI,eAAe,iBAAiB;AAClC,QAAI,IAAI,SAAS,kBAAkB;AACjC,aAAO,MAAM,wCAAwC,EAAE,IAAI,CAAC;AAC5D,0BAAoB,GAAG,YAAY,KAAK;AAAA,QACtC,QAAQ;AAAA,QACR,MAAM;AAAA,MACR,CAAC;AACD,aAAO,aAAa,KAAK,EAAE,OAAO,0BAA0B,MAAM,IAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC/F;AACA,WAAO,aAAa,KAAK,EAAE,OAAO,IAAI,SAAS,MAAM,IAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AACA,SAAO;AACT;AAEA,eAAsB,KAAK,KAAc,SAA0C;AACjF,QAAM,EAAE,SAAS,IAAI,MAAM,QAAQ;AACnC,MAAI,CAAC,iBAAiB,KAAK,QAAQ,GAAG;AACpC,WAAO,aAAa,KAAK,EAAE,OAAO,mBAAmB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzE;AAEA,QAAM,UAAU,kBAAkB,QAAQ;AAC1C,MAAI,CAAC,SAAS;AACZ,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,mCAAmC,QAAQ,GAAG;AAAA,MACvD,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACA,MAAI,OAAO,QAAQ,2BAA2B,YAAY;AACxD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa,QAAQ,uDAAuD;AAAA,MACrF,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,OAAO,CAAC,MAAM,UAAU;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACJ,MAAI;AACF,UAAM,OAAO,MAAM,aAAa,KAAK,CAAC,CAAC;AACvC,WAAO,mBAAmB,MAAM,QAAQ,CAAC,CAAC;AAAA,EAC5C,SAAS,KAAK;AACZ,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,eAAe,QAAQ,IAAI,UAAU,uBAAuB;AAAA,MACrE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,sBAAsB,MAAM;AAChC,QAAI;AACF,aAAO,UAAU,QAAQ,+BAA+B;AAAA,IAC1D,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,GAAG;AAMH,QAAM,cAAc,MAAM,8BAA8B,oBAAoB,UAAU;AAAA,IACpF,UAAU,KAAK;AAAA,IACf,gBAAiB,KAAmC,SAAS;AAAA,EAC/D,CAAC;AACD,MAAI,CAAC,aAAa;AAChB,WAAO,aAAa;AAAA,MAClB;AAAA,QACE,OACE,GAAG,QAAQ;AAAA,QACb,MAAM;AAAA,MACR;AAAA,MACA,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,cAAc,mBAAmB,KAAK,QAAQ;AACpD,MAAI;AACJ,MAAI;AACF,oBAAgB,iBAAiB;AAAA,MAC/B,QAAQ,KAAK;AAAA,MACb,UAAU,KAAK;AAAA,MACf,gBAAiB,KAAmC,SAAS;AAAA,MAC7D,aAAa;AAAA,MACb,WAAW,wBAAwB,KAAK,WAAW,wBAAwB;AAAA,IAC7E,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,UAAMA,YAAW,wBAAwB,GAAG;AAC5C,QAAIA,UAAU,QAAOA;AACrB,UAAM;AAAA,EACR;AAEA,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,QAAQ,uBAAuB;AAAA,MAC5C,OAAO,cAAc;AAAA,MACrB,OAAO,cAAc,QAAQ;AAAA,MAC7B;AAAA,MACA;AAAA,MACA,OAAO;AAAA,QACL,UAAU,KAAK;AAAA,QACf,gBACG,KAAmC,SAAU,KAAK;AAAA,MACvD;AAAA,MACA,WAAW,KAAK;AAAA,IAClB,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,QAAI,eAAe,iBAAiB;AAClC,aAAO,aAAa,KAAK,EAAE,OAAO,IAAI,SAAS,MAAM,IAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAClF;AACA,UAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,WAAO,aAAa,KAAK,EAAE,OAAO,QAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9D;AAIA,MAAI;AACJ,MAAI,OAAO,OAAO;AAChB,QAAI;AACF,qBAAe,MAAM,OAAO,gCAAgC,GAAG,kBAAkB;AAAA,QAC/E,GAAG,cAAc;AAAA,QACjB,OAAO,EAAE,GAAI,cAAc,QAAQ,SAAS,CAAC,GAAI,GAAG,OAAO,MAAM;AAAA,MACnE,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,YAAMA,YAAW,wBAAwB,GAAG;AAC5C,UAAIA,UAAU,QAAOA;AACrB,YAAM;AAAA,IACR;AAAA,EACF,OAAO;AACL,kBAAc,cAAc;AAAA,EAC9B;AAEA,QAAM,WAAW,aAAa,KAAK,EAAE,cAAc,OAAO,aAAa,CAAC;AACxE,WAAS,QAAQ,IAAI;AAAA,IACnB,MAAM;AAAA,IACN,OAAO;AAAA,IACP,UAAU;AAAA,IACV,QAAQ,QAAQ,IAAI,aAAa;AAAA,IACjC,UAAU;AAAA,IACV,MAAM;AAAA,IACN,QAAQ,KAAK,MAAM,4CAA4C,GAAI;AAAA,EACrE,CAAC;AACD,SAAO;AACT;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,uBAAuB;AAAA,EAC9B,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB;AAAA,MAC9B,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,mCAAmC;AAAA,QAC/D,EAAE,QAAQ,KAAK,aAAa,6CAA6C;AAAA,QACzE,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,QAC3C,EAAE,QAAQ,KAAK,aAAa,uBAAuB;AAAA,QACnD,EAAE,QAAQ,KAAK,aAAa,wDAAwD;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,wCAAwC;AAAA,MACtE;AAAA,IACF;AAAA,EACF;AACF;AACA,IAAO,gBAAQ;",
|
|
6
|
+
"names": ["response"]
|
|
7
7
|
}
|
|
@@ -40,7 +40,10 @@ function getSecret() {
|
|
|
40
40
|
const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY;
|
|
41
41
|
if (dedicated) return dedicated;
|
|
42
42
|
if (process.env.NODE_ENV === "production") {
|
|
43
|
-
throw new
|
|
43
|
+
throw new OAuthStateError(
|
|
44
|
+
"[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production",
|
|
45
|
+
"missing_secret"
|
|
46
|
+
);
|
|
44
47
|
}
|
|
45
48
|
const fallback = process.env.JWT_SECRET;
|
|
46
49
|
if (!fallback) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/oauth-state.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new Error('[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production')\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,oBAAoB;AAE7B,MAAM,mBAAmB,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,cAAc,CAAC;AA8BlG,MAAM,YAAY;AAClB,MAAM,YAAY;AAClB,MAAM,aAAa;AACZ,MAAM,4CAA4C,IAAI,KAAK;AAClE,MAAM,YAAY,OAAO,KAAK,sCAAsC;AACpE,MAAM,YAAY,OAAO,KAAK,2CAA2C;AAElE,MAAM,iDACX;AAEK,MAAM,2BAA2B;AAGjC,MAAM,+CACX;AAGK,MAAM,wBAAwB,MAAM;AAAA,EAEzC,YACE,SACS,MAOT;AACA,UAAM,OAAO;AARJ;AAHX,SAAS,OAAO;AAAA,EAYhB;AACF;AA0CO,SAAS,qBAAqB,OAAmD;AACtF,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,MAAM,WAAW,KAAK,MAAM,SAAS,KAAM,QAAO;AACtD,MAAI,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AAC7D,MAAI,MAAM,SAAS,IAAI,EAAG,QAAO;AACjC,MAAI;AACF,UAAM,OAAO,IAAI,IAAI,4BAA4B;AACjD,UAAM,SAAS,IAAI,IAAI,OAAO,IAAI;AAClC,WAAO,OAAO,WAAW,KAAK,UAAU,OAAO,SAAS,WAAW,GAAG;AAAA,EACxE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,wBACd,OACA,WAAmB,0BACX;AACR,SAAO,qBAAqB,KAAK,IAAI,QAAQ;AAC/C;AAEA,SAAS,UAAU,QAAwB;AACzC,SAAO,OAAO,KAAK,OAAO,SAAS,UAAU,QAAQ,WAAW,WAAW,EAAE,CAAC;AAChF;AAEA,SAAS,YAAoB;AAC3B,QAAM,YAAY,QAAQ,IAAI,0BAA0B,QAAQ,IAAI;AACpE,MAAI,UAAW,QAAO;AAMtB,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,UAAM,IAAI,
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new OAuthStateError(\n '[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production',\n 'missing_secret',\n )\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -32,6 +32,16 @@ function useConnectChannel(options) {
|
|
|
32
32
|
});
|
|
33
33
|
const body = response.result;
|
|
34
34
|
if (!response.ok || !body?.authorizeUrl) {
|
|
35
|
+
if (body?.code === "missing_secret") {
|
|
36
|
+
flash(
|
|
37
|
+
t(
|
|
38
|
+
"communication_channels.profile.connect.notAvailable",
|
|
39
|
+
"Mailbox connection is not available. Ask an administrator to configure the OAuth state key."
|
|
40
|
+
),
|
|
41
|
+
"error"
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
35
45
|
if (body?.code === "oauth_client_not_configured") {
|
|
36
46
|
flash(
|
|
37
47
|
t(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/use-connect-channel.ts"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
|
|
5
|
-
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'missing_secret') {\n flash(\n t(\n 'communication_channels.profile.connect.notAvailable',\n 'Mailbox connection is not available. Ask an administrator to configure the OAuth state key.',\n ),\n 'error',\n )\n return\n }\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,kBAAkB;AACnC;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7451.1.83d4762b57",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -264,16 +264,16 @@
|
|
|
264
264
|
"zod": "^4.4.3"
|
|
265
265
|
},
|
|
266
266
|
"peerDependencies": {
|
|
267
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
268
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
269
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
267
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7451.1.83d4762b57",
|
|
268
|
+
"@open-mercato/shared": "0.8.1-develop.7451.1.83d4762b57",
|
|
269
|
+
"@open-mercato/ui": "0.8.1-develop.7451.1.83d4762b57",
|
|
270
270
|
"react": "^19.0.0",
|
|
271
271
|
"react-dom": "^19.0.0"
|
|
272
272
|
},
|
|
273
273
|
"devDependencies": {
|
|
274
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
275
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
276
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
274
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7451.1.83d4762b57",
|
|
275
|
+
"@open-mercato/shared": "0.8.1-develop.7451.1.83d4762b57",
|
|
276
|
+
"@open-mercato/ui": "0.8.1-develop.7451.1.83d4762b57",
|
|
277
277
|
"@testing-library/dom": "^10.4.1",
|
|
278
278
|
"@testing-library/jest-dom": "^7.0.1",
|
|
279
279
|
"@testing-library/react": "^16.3.3",
|
|
@@ -4,6 +4,8 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
6
6
|
import { toAbsoluteUrl } from '@open-mercato/shared/lib/url'
|
|
7
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
8
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
7
9
|
import { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'
|
|
8
10
|
import { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'
|
|
9
11
|
import {
|
|
@@ -16,6 +18,8 @@ import {
|
|
|
16
18
|
OAuthStateError,
|
|
17
19
|
} from '../../../../../lib/oauth-state'
|
|
18
20
|
|
|
21
|
+
const logger = createLogger('communication_channels').child({ component: 'oauth-initiate' })
|
|
22
|
+
|
|
19
23
|
export const metadata = {
|
|
20
24
|
path: '/communication_channels/oauth/[provider]/initiate',
|
|
21
25
|
POST: {
|
|
@@ -56,6 +60,21 @@ function defaultRedirectUri(req: Request, providerKey: string): string {
|
|
|
56
60
|
return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
function oauthStateErrorResponse(err: unknown): Response | null {
|
|
64
|
+
if (err instanceof OAuthStateError) {
|
|
65
|
+
if (err.code === 'missing_secret') {
|
|
66
|
+
logger.error('OAuth state secret is not configured', { err })
|
|
67
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
68
|
+
module: 'communication_channels',
|
|
69
|
+
code: 'communication_channels.missing_secret',
|
|
70
|
+
})
|
|
71
|
+
return NextResponse.json({ error: 'OAuth is not available', code: err.code }, { status: 500 })
|
|
72
|
+
}
|
|
73
|
+
return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
export async function POST(req: Request, context: RouteContext): Promise<Response> {
|
|
60
79
|
const { provider } = await context.params
|
|
61
80
|
if (!/^[a-z0-9_-]+$/i.test(provider)) {
|
|
@@ -121,13 +140,20 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
const redirectUri = defaultRedirectUri(req, provider)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
let stateEnvelope: ReturnType<typeof createOAuthState>
|
|
144
|
+
try {
|
|
145
|
+
stateEnvelope = createOAuthState({
|
|
146
|
+
userId: auth.sub as string,
|
|
147
|
+
tenantId: auth.tenantId as string,
|
|
148
|
+
organizationId: (auth as { orgId?: string | null }).orgId ?? null,
|
|
149
|
+
providerKey: provider,
|
|
150
|
+
returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),
|
|
151
|
+
})
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const response = oauthStateErrorResponse(err)
|
|
154
|
+
if (response) return response
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
131
157
|
|
|
132
158
|
let result
|
|
133
159
|
try {
|
|
@@ -153,12 +179,21 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
153
179
|
|
|
154
180
|
// If the adapter packed extras (PKCE verifier, scopes), bake them into the
|
|
155
181
|
// state cookie now so the callback handler can pass them to exchangeOAuthCode.
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
let finalCookie: string
|
|
183
|
+
if (result.extra) {
|
|
184
|
+
try {
|
|
185
|
+
finalCookie = (await import('../../../../../lib/oauth-state')).encryptOAuthState({
|
|
158
186
|
...stateEnvelope.payload,
|
|
159
187
|
extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },
|
|
160
188
|
})
|
|
161
|
-
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const response = oauthStateErrorResponse(err)
|
|
191
|
+
if (response) return response
|
|
192
|
+
throw err
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
finalCookie = stateEnvelope.cookie
|
|
196
|
+
}
|
|
162
197
|
|
|
163
198
|
const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })
|
|
164
199
|
response.cookies.set({
|
|
@@ -184,6 +219,7 @@ export const openApi = {
|
|
|
184
219
|
{ status: 400, description: 'Invalid provider or unsupported (no OAuth)' },
|
|
185
220
|
{ status: 401, description: 'Unauthorized' },
|
|
186
221
|
{ status: 422, description: 'Invalid request body' },
|
|
222
|
+
{ status: 500, description: 'OAuth state secret missing or state encryption failed' },
|
|
187
223
|
{ status: 502, description: 'Adapter failed to build authorize URL' },
|
|
188
224
|
],
|
|
189
225
|
},
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"communication_channels.profile.connect.imapTitle": "IMAP-Postfach verbinden",
|
|
92
92
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Dieses Postfach ist bereits über einen anderen Anbieter verbunden. Trennen Sie es zuerst, um es mit einem anderen Anbieter erneut zu verbinden.",
|
|
93
93
|
"communication_channels.profile.connect.menu": "Kanal verbinden",
|
|
94
|
+
"communication_channels.profile.connect.notAvailable": "Die Verbindung mit einem Postfach ist nicht verfügbar. Bitten Sie einen Administrator, den OAuth-Zustandsschlüssel zu konfigurieren.",
|
|
94
95
|
"communication_channels.profile.connect.notConfigured": "Dieser Anbieter ist noch nicht konfiguriert. Bitten Sie einen Administrator, die OAuth-Client-ID und das Secret unter Integrationen zu hinterlegen, bevor ein Postfach verbunden wird.",
|
|
95
96
|
"communication_channels.profile.connect.oauthFailed": "OAuth-Verbindung konnte nicht gestartet werden.",
|
|
96
97
|
"communication_channels.profile.connect.save": "Verbinden",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"communication_channels.profile.connect.imapTitle": "Connect IMAP mailbox",
|
|
92
92
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.",
|
|
93
93
|
"communication_channels.profile.connect.menu": "Connect channel",
|
|
94
|
+
"communication_channels.profile.connect.notAvailable": "Mailbox connection is not available. Ask an administrator to configure the OAuth state key.",
|
|
94
95
|
"communication_channels.profile.connect.notConfigured": "This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.",
|
|
95
96
|
"communication_channels.profile.connect.oauthFailed": "Could not start OAuth connection.",
|
|
96
97
|
"communication_channels.profile.connect.save": "Connect",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"communication_channels.profile.connect.imapTitle": "Conectar buzón IMAP",
|
|
92
92
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Este buzón ya está conectado a través de otro proveedor. Desconéctalo primero para volver a conectarlo con uno diferente.",
|
|
93
93
|
"communication_channels.profile.connect.menu": "Conectar canal",
|
|
94
|
+
"communication_channels.profile.connect.notAvailable": "La conexión del buzón no está disponible. Pide a un administrador que configure la clave de estado de OAuth.",
|
|
94
95
|
"communication_channels.profile.connect.notConfigured": "Este proveedor aún no está configurado. Pide a un administrador que añada el ID de cliente y el secreto de OAuth en Integraciones antes de conectar un buzón.",
|
|
95
96
|
"communication_channels.profile.connect.oauthFailed": "No se pudo iniciar la conexión OAuth.",
|
|
96
97
|
"communication_channels.profile.connect.save": "Conectar",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"communication_channels.profile.connect.imapTitle": "IMAP 메일함 연결",
|
|
92
92
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "이 메일함은 이미 다른 제공업체를 통해 연결되어 있습니다. 다른 제공업체로 다시 연결하려면 먼저 연결을 해제하세요.",
|
|
93
93
|
"communication_channels.profile.connect.menu": "채널 연결",
|
|
94
|
+
"communication_channels.profile.connect.notAvailable": "메일함 연결을 사용할 수 없습니다. 관리자에게 OAuth 상태 키를 구성하도록 요청하세요.",
|
|
94
95
|
"communication_channels.profile.connect.notConfigured": "이 제공업체는 아직 구성되지 않았습니다. 메일함을 연결하기 전에 관리자에게 통합에서 OAuth 클라이언트 ID와 시크릿을 추가하도록 요청하세요.",
|
|
95
96
|
"communication_channels.profile.connect.oauthFailed": "OAuth 연결을 시작할 수 없습니다.",
|
|
96
97
|
"communication_channels.profile.connect.save": "연결",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"communication_channels.profile.connect.imapTitle": "Połącz skrzynkę IMAP",
|
|
92
92
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Ta skrzynka jest już połączona przez innego dostawcę. Najpierw ją rozłącz, aby połączyć ją z innym dostawcą.",
|
|
93
93
|
"communication_channels.profile.connect.menu": "Połącz kanał",
|
|
94
|
+
"communication_channels.profile.connect.notAvailable": "Połączenie skrzynki pocztowej jest niedostępne. Poproś administratora o skonfigurowanie klucza stanu OAuth.",
|
|
94
95
|
"communication_channels.profile.connect.notConfigured": "Ten dostawca nie został jeszcze skonfigurowany. Poproś administratora o dodanie identyfikatora klienta i sekretu OAuth w sekcji Integracje przed połączeniem skrzynki.",
|
|
95
96
|
"communication_channels.profile.connect.oauthFailed": "Nie udało się rozpocząć połączenia OAuth.",
|
|
96
97
|
"communication_channels.profile.connect.save": "Połącz",
|
|
@@ -138,7 +138,10 @@ function getSecret(): string {
|
|
|
138
138
|
// state cookies, bypassing the userId/tenant binding. In non-production we fall
|
|
139
139
|
// back to JWT_SECRET so dev/test envs that only configure one secret still work.
|
|
140
140
|
if (process.env.NODE_ENV === 'production') {
|
|
141
|
-
throw new
|
|
141
|
+
throw new OAuthStateError(
|
|
142
|
+
'[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production',
|
|
143
|
+
'missing_secret',
|
|
144
|
+
)
|
|
142
145
|
}
|
|
143
146
|
const fallback = process.env.JWT_SECRET
|
|
144
147
|
if (!fallback) {
|
|
@@ -46,6 +46,16 @@ export function useConnectChannel(options: {
|
|
|
46
46
|
})
|
|
47
47
|
const body = response.result as InitiateResponse | undefined
|
|
48
48
|
if (!response.ok || !body?.authorizeUrl) {
|
|
49
|
+
if (body?.code === 'missing_secret') {
|
|
50
|
+
flash(
|
|
51
|
+
t(
|
|
52
|
+
'communication_channels.profile.connect.notAvailable',
|
|
53
|
+
'Mailbox connection is not available. Ask an administrator to configure the OAuth state key.',
|
|
54
|
+
),
|
|
55
|
+
'error',
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
49
59
|
if (body?.code === 'oauth_client_not_configured') {
|
|
50
60
|
flash(
|
|
51
61
|
t(
|