@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.
@@ -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
- const stateEnvelope = createOAuthState({
92
- userId: auth.sub,
93
- tenantId: auth.tenantId,
94
- organizationId: auth.orgId ?? null,
95
- providerKey: provider,
96
- returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
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
- const finalCookie = result.extra ? (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
119
- ...stateEnvelope.payload,
120
- extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
121
- }) : stateEnvelope.cookie;
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 const 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\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 const finalCookie = result.extra\n ? (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n : stateEnvelope.cookie\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: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "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",
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 Error("[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production");
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": "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",
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.7449.1.eecc56d25f",
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.7449.1.eecc56d25f",
268
- "@open-mercato/shared": "0.8.1-develop.7449.1.eecc56d25f",
269
- "@open-mercato/ui": "0.8.1-develop.7449.1.eecc56d25f",
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.7449.1.eecc56d25f",
275
- "@open-mercato/shared": "0.8.1-develop.7449.1.eecc56d25f",
276
- "@open-mercato/ui": "0.8.1-develop.7449.1.eecc56d25f",
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
- const stateEnvelope = createOAuthState({
125
- userId: auth.sub as string,
126
- tenantId: auth.tenantId as string,
127
- organizationId: (auth as { orgId?: string | null }).orgId ?? null,
128
- providerKey: provider,
129
- returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),
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
- const finalCookie = result.extra
157
- ? (await import('../../../../../lib/oauth-state')).encryptOAuthState({
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
- : stateEnvelope.cookie
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 Error('[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production')
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(