diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 30217bc..cec08e0 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -45,22 +45,36 @@ read that subscriber's inbox, your backend computes a hash and passes it to ``: ``` -subscriberHash = hex(HMAC-SHA256(environment_secret, subscriber_id)) +subscriberHash = hex(HMAC-SHA256(environment_secret, environment_id + "\0" + subscriber_id)) ``` The `environment_secret` is the environment's `subscriber_hmac_secret`, -readable in the admin dashboard (developer or admin role). Compute the hash in +readable in the admin dashboard (developer or admin role). The +`environment_id` is the environment's id (`env_...`), shown on the same +dashboard page; it is joined to the subscriber id with a NUL byte and binds +the hash to that environment, so a hash never transfers between environments +even if two environments were ever given the same secret. Compute the hash in your backend, per request: ```ts import { createHmac } from 'node:crypto'; const subscriberHash = createHmac('sha256', process.env.CHIMELY_HMAC_SECRET!) - .update(subscriberId) + .update(`${process.env.CHIMELY_ENVIRONMENT_ID}\0${subscriberId}`) .digest('hex'); // pass subscriberHash to ``` + +Hashes computed with the previous formula, +`hex(HMAC-SHA256(environment_secret, subscriber_id))`, are still accepted as +a fallback. That fallback will be dropped in an announced minor release, and +the cross-environment binding is only effective once it is gone: until then, a +legacy hash minted in one environment also verifies in any other environment +given the same secret. Switch your backend to the environment-bound formula +now. + + The secret signs every subscriber's identity for an environment. Computing the hash client-side leaks it and lets anyone read any inbox. Sign on the server diff --git a/docs/openapi/chimely.yaml b/docs/openapi/chimely.yaml index 8751c3e..a0013e0 100644 --- a/docs/openapi/chimely.yaml +++ b/docs/openapi/chimely.yaml @@ -10,8 +10,12 @@ info: by the key. * **Subscriber plane** — called by `@chimely/client` (the `` widget) on behalf of one end user. Authenticated with an HMAC subscriber - hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))` - computed by the customer's backend. Mandatory in environments with + hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, + environment_id + "\0" + subscriber_id))` computed by the customer's + backend, where `environment_id` is the environment's TypeID (`env_...`) + as shown in the admin dashboard. Hashes computed with the legacy input + (`subscriber_id` alone) are still accepted; that fallback is removed at + an announced minor version bump. Mandatory in environments with `require_subscriber_hash = true` (the production default); optional in dev environments so the quickstart works without a backend. diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 67d8afa..7f5b53c 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -41,7 +41,9 @@ SSE hint and the widget refetches conditionally (ETag, mostly 304s). ## Production differences - Set `subscriberHash` on `` — `hex(HMAC-SHA256(secret, - subscriberId))`, computed by **your backend**, never in the browser. + environmentId + "\0" + subscriberId))`, computed by **your backend**, + never in the browser. The environment id is the `env_...` id from the + admin dashboard. The dev bootstrap turns the requirement off; production environments keep it on. - `CHIMELY_DEV_ENVIRONMENT` / `CHIMELY_DEV_API_KEY` are for local diff --git a/packages/client/README.md b/packages/client/README.md index bd90351..11c4e74 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -19,8 +19,9 @@ const client = new ChimelyClient({ serverUrl: 'https://chimely.example.com', environment: 'production', subscriberId: 'usr_123', - // Computed by YOUR backend: hex(HMAC-SHA256(environment_secret, subscriberId)). - // Never compute it in the browser. + // Computed by YOUR backend, never in the browser: + // hex(HMAC-SHA256(environment_secret, environmentId + "\0" + subscriberId)), + // where environmentId is the env_... id from the admin dashboard. subscriberHash, }); @@ -32,6 +33,10 @@ const unsubscribe = client.subscribe(() => { client.connect(); ``` +Hashes minted with the legacy input (`subscriberId` alone) are still accepted; +that fallback is removed in an announced minor release. See +[Auth and the subscriber hash](https://chimely.dev/docs/auth). + The snapshot is immutable with a new identity per change, so it plugs straight into `useSyncExternalStore` or any equality-based renderer. Mutations (`markRead`, `markUnread`, `archive`, `markAllRead`, ...) apply optimistically diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 31d1c3f..28f4d71 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -109,9 +109,11 @@ export interface ChimelyClientConfig { /** Customer-provided subscriber id of the current user. */ subscriberId: string; /** - * HMAC-SHA256(secret, subscriberId) hex, computed by YOUR backend. - * Required in production environments. Omittable only where the - * environment allows it (dev quickstart). + * hex(HMAC-SHA256(secret, environmentId + "\0" + subscriberId)), computed + * by YOUR backend. The environmentId is the `env_...` id from the admin + * dashboard. Legacy hashes over subscriberId alone are accepted until an + * announced minor release. Required in production environments. Omittable + * only where the environment allows it (dev quickstart). */ subscriberHash?: string; backoff?: BackoffConfig; diff --git a/packages/react/README.md b/packages/react/README.md index 00d7b20..671ec56 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -26,7 +26,10 @@ import { Inbox } from '@chimely/react'; That is a live bell with an unseen badge, a popover inbox with unread state, tabs, filters, archive, infinite scroll, per-category preferences, and SSE live updates. `subscriberHash` is `hex(HMAC-SHA256(environment_secret, -subscriberId))`, computed by **your backend**, never in the browser. See +environmentId + "\0" + subscriberId))`, computed by **your backend**, never +in the browser. The environment id is the `env_...` id from the admin +dashboard. Legacy hashes over `subscriberId` alone are accepted until an +announced minor release. See [Auth and the subscriber hash](https://chimely.dev/docs/auth). ## Hooks and composables diff --git a/server/admin/src/routes/environment-detail.tsx b/server/admin/src/routes/environment-detail.tsx index 1f1544d..fbfbf1f 100644 --- a/server/admin/src/routes/environment-detail.tsx +++ b/server/admin/src/routes/environment-detail.tsx @@ -272,11 +272,19 @@ function HmacTab({ envId }: { envId: string }) { Subscriber HMAC secret - The customer backend computes HMAC-SHA256(secret, subscriber_id) with - this. Rotation uses two slots so live widget sessions never break. + The customer backend computes{' '} + {'HMAC-SHA256(secret, environment_id + "\\0" + subscriber_id)'} with this + secret and the environment id below. Legacy hashes over{' '} + subscriber_id alone verify until an announced minor release. Rotation + uses two slots so live widget sessions never break. +
+ + +
+
diff --git a/server/src/auth.rs b/server/src/auth.rs index b6b7686..a6d8015 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -5,9 +5,11 @@ //! //! Subscriber: environment slug, customer subscriber id, and (when the //! environment requires it) `hex(HMAC-SHA256(subscriber_hmac_secret, -//! subscriber_id))`, verified against the current then the previous secret -//! slot so secret rotation never invalidates live sessions. Headers with -//! query-parameter fallbacks (EventSource cannot set headers). +//! env_typeid || 0x00 || subscriber_id))`, verified against the current then +//! the previous secret slot so secret rotation never invalidates live +//! sessions. The legacy input (`subscriber_id` alone) is still accepted +//! until the announced minor version bump. Headers with query-parameter +//! fallbacks (EventSource cannot set headers). //! //! Admin: built-in users (Argon2id passwords) with a server-side session in //! an HttpOnly, SameSite=Strict cookie scoped to /admin. Roles are @@ -378,11 +380,14 @@ impl FromRequestParts for SubscriberAuth { match &hash { Some(hash) => { - let valid = verify_subscriber_hash(&env.subscriber_hmac_secret, &external_id, hash) - || env - .subscriber_hmac_secret_previous - .as_deref() - .is_some_and(|prev| verify_subscriber_hash(prev, &external_id, hash)); + let valid = + verify_subscriber_hash(&env.subscriber_hmac_secret, env.id, &external_id, hash) + || env + .subscriber_hmac_secret_previous + .as_deref() + .is_some_and(|prev| { + verify_subscriber_hash(prev, env.id, &external_id, hash) + }); if !valid { return Err(ApiError::unauthorized("invalid subscriber hash")); } @@ -405,22 +410,54 @@ impl FromRequestParts for SubscriberAuth { } } -/// `hex(HMAC-SHA256(secret, subscriber_id))`, constant-time comparison. -pub fn verify_subscriber_hash(secret: &str, subscriber_id: &str, hash_hex: &str) -> bool { +/// The MAC input binds the hash to its environment. The environment id is +/// the public TypeID form (`env_...`), the same string the admin dashboard +/// displays next to the secret. +fn subscriber_mac_input(environment_id: Uuid, subscriber_id: &str) -> Vec { + let mut input = ids::typeid(ids::ENVIRONMENT, environment_id).into_bytes(); + input.push(0); + input.extend_from_slice(subscriber_id.as_bytes()); + input +} + +fn hmac_matches(secret: &str, message: &[u8], provided: &[u8]) -> bool { + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); + mac.update(message); + mac.verify_slice(provided).is_ok() +} + +/// `hex(HMAC-SHA256(secret, env_typeid || 0x00 || subscriber_id))`, +/// constant-time comparison. +pub fn verify_subscriber_hash( + secret: &str, + environment_id: Uuid, + subscriber_id: &str, + hash_hex: &str, +) -> bool { let Ok(provided) = hex::decode(hash_hex) else { return false; }; - let mut mac = - Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); - mac.update(subscriber_id.as_bytes()); - mac.verify_slice(&provided).is_ok() + if hmac_matches( + secret, + &subscriber_mac_input(environment_id, subscriber_id), + &provided, + ) { + return true; + } + // Legacy formula fallback (issue #55 dual-accept rollout). Hashes minted + // over `subscriber_id` alone stay valid until the announced minor version + // bump. A legacy hash carries no environment binding, so environments + // sharing a secret accept each other's legacy hashes for the duration of + // the window. This check is removed at that bump. + hmac_matches(secret, subscriber_id.as_bytes(), &provided) } /// Test/SDK helper: compute the subscriber hash the customer backend would. -pub fn compute_subscriber_hash(secret: &str, subscriber_id: &str) -> String { +pub fn compute_subscriber_hash(secret: &str, environment_id: Uuid, subscriber_id: &str) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("hmac accepts any key length"); - mac.update(subscriber_id.as_bytes()); + mac.update(&subscriber_mac_input(environment_id, subscriber_id)); hex::encode(mac.finalize().into_bytes()) } diff --git a/server/src/openapi.rs b/server/src/openapi.rs index 20dc7d5..4d0cec3 100644 --- a/server/src/openapi.rs +++ b/server/src/openapi.rs @@ -17,8 +17,12 @@ const INFO_DESCRIPTION: &str = r#"Two planes, one binary: by the key. * **Subscriber plane** — called by `@chimely/client` (the `` widget) on behalf of one end user. Authenticated with an HMAC subscriber - hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, subscriber_id))` - computed by the customer's backend. Mandatory in environments with + hash: `hex(HMAC-SHA256(environment.subscriber_hmac_secret, + environment_id + "\0" + subscriber_id))` computed by the customer's + backend, where `environment_id` is the environment's TypeID (`env_...`) + as shown in the admin dashboard. Hashes computed with the legacy input + (`subscriber_id` alone) are still accepted; that fallback is removed at + an announced minor version bump. Mandatory in environments with `require_subscriber_hash = true` (the production default); optional in dev environments so the quickstart works without a backend. diff --git a/server/tests/admin.rs b/server/tests/admin.rs index 68cd46e..5f8339b 100644 --- a/server/tests/admin.rs +++ b/server/tests/admin.rs @@ -39,7 +39,7 @@ async fn counts_status_with_secret( subscriber: &str, secret: &str, ) -> reqwest::StatusCode { - let hash = compute_subscriber_hash(secret, subscriber); + let hash = compute_subscriber_hash(secret, app.env.id, subscriber); app.client .get(format!("{}/v1/inbox/counts", app.base)) .header("X-Chimely-Environment", app.env.slug.clone()) diff --git a/server/tests/auth.rs b/server/tests/auth.rs index f9ca77d..f1672aa 100644 --- a/server/tests/auth.rs +++ b/server/tests/auth.rs @@ -39,7 +39,7 @@ async fn get_counts_status(app: &support::TestApp, h: HeaderMap) -> u16 { #[tokio::test] async fn hash_is_mandatory_when_the_environment_requires_it() { let app = support::spawn().await; // require_subscriber_hash = true - let good = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let good = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, None)).await, @@ -52,7 +52,7 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { "wrong hash" ); // A valid hash for a DIFFERENT subscriber id must not transfer. - let other = compute_subscriber_hash(&app.env.hmac_secret, "usr_else"); + let other = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, "usr_else"); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&other))).await, 401 @@ -77,6 +77,68 @@ async fn hash_is_mandatory_when_the_environment_requires_it() { assert_eq!(get_counts_status(&app, h).await, 401); } +/// The pre-#55 formula: HMAC over subscriber_id alone, no environment input. +fn legacy_hash(secret: &str, sub: &str) -> String { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(sub.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Dual-accept rollout for the env-bound hash formula (issue #55). A hash +/// minted with the legacy input (subscriber_id alone) still authenticates. +/// This test is deleted when the legacy fallback is dropped at the announced +/// minor version bump. +#[tokio::test] +async fn legacy_subscriber_id_only_hash_still_authenticates() { + let app = support::spawn().await; + let legacy = legacy_hash(&app.env.hmac_secret, SUB); + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 200, + "legacy-formula hashes must keep working until the announced removal" + ); +} + +/// A customer still on the legacy formula rotates their secret. Their +/// pre-rotation hash must verify through the previous-slot fallback, the same +/// overlap guarantee new-formula hashes get. Deleted with the legacy fallback. +#[tokio::test] +async fn legacy_hash_survives_rotation_via_previous_slot() { + let app = support::spawn().await; + let legacy = legacy_hash(&app.env.hmac_secret, SUB); + + sqlx::query( + "UPDATE environments SET + subscriber_hmac_secret = 'shmac_rotated_secret', + subscriber_hmac_secret_previous = subscriber_hmac_secret, + subscriber_hmac_rotated_at = now() + WHERE id = $1", + ) + .bind(app.env.id) + .execute(&app.pool) + .await + .unwrap(); + + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 200, + "legacy hashes ride the rotation overlap via the previous slot" + ); + + // Rotation ends: the legacy hash dies with the previous slot. + sqlx::query("UPDATE environments SET subscriber_hmac_secret_previous = NULL WHERE id = $1") + .bind(app.env.id) + .execute(&app.pool) + .await + .unwrap(); + assert_eq!( + get_counts_status(&app, headers(&app.env.slug, SUB, Some(&legacy))).await, + 401 + ); +} + #[tokio::test] async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { let app = support::spawn_dev_mode().await; // require_subscriber_hash = false @@ -91,7 +153,7 @@ async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { get_counts_status(&app, headers(&app.env.slug, SUB, Some("deadbeef"))).await, 401 ); - let good = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let good = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&good))).await, 200 @@ -102,7 +164,7 @@ async fn dev_mode_environments_accept_missing_but_not_invalid_hashes() { async fn rotation_verifies_current_then_previous_secret() { let app = support::spawn().await; let old_secret = app.env.hmac_secret.clone(); - let old_hash = compute_subscriber_hash(&old_secret, SUB); + let old_hash = compute_subscriber_hash(&old_secret, app.env.id, SUB); // Rotate: new secret current, old secret in the previous slot. let new_secret = "shmac_rotated_secret"; @@ -124,7 +186,7 @@ async fn rotation_verifies_current_then_previous_secret() { get_counts_status(&app, headers(&app.env.slug, SUB, Some(&old_hash))).await, 200 ); - let new_hash = compute_subscriber_hash(new_secret, SUB); + let new_hash = compute_subscriber_hash(new_secret, app.env.id, SUB); assert_eq!( get_counts_status(&app, headers(&app.env.slug, SUB, Some(&new_hash))).await, 200 @@ -149,7 +211,7 @@ async fn rotation_verifies_current_then_previous_secret() { #[tokio::test] async fn query_parameter_fallbacks_match_the_headers() { let app = support::spawn().await; - let hash = compute_subscriber_hash(&app.env.hmac_secret, SUB); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); // Pure query auth (the EventSource case) on a regular endpoint. let res = app diff --git a/server/tests/redteam_cross_env.rs b/server/tests/redteam_cross_env.rs index 2f1ff1e..bd4d331 100644 --- a/server/tests/redteam_cross_env.rs +++ b/server/tests/redteam_cross_env.rs @@ -132,6 +132,138 @@ async fn counter_updated_at(pool: &sqlx::PgPool, env: Uuid, subscriber: &str) -> .expect("counter updated_at") } +/// The documented subscriber hash, recomputed with the hmac crate directly +/// so the expectation is independent of the server helper. The environment +/// id is the public TypeID form, the string the admin dashboard displays. +fn env_bound_hash(secret: &str, env_typeid: &str, subscriber: &str) -> String { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(env_typeid.as_bytes()); + mac.update(&[0]); + mac.update(subscriber.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +fn subscriber_headers_with_hash(slug: &str, subscriber: &str, hash: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Chimely-Environment", + HeaderValue::from_str(slug).unwrap(), + ); + headers.insert( + "X-Chimely-Subscriber", + HeaderValue::from_str(subscriber).unwrap(), + ); + headers.insert( + "X-Chimely-Subscriber-Hash", + HeaderValue::from_str(hash).unwrap(), + ); + headers +} + +/// Issue #55. Two environments end up sharing one secret (manual DB edit or +/// restored dump). The MAC input is env_typeid || 0x00 || subscriber_id, so +/// an env-bound hash minted for env A authenticates in env A only. Presented +/// under env B's slug it is rejected even though env B verifies with the +/// same secret, and the probe leaves no subscriber row behind in env B. +/// Legacy-formula hashes carry no such binding during the dual-accept +/// window. shared_secret_legacy_hash_cross_authenticates_until_removal pins +/// that window. +#[tokio::test] +async fn shared_secret_env_a_hash_is_rejected_by_env_b() { + let app = support::spawn().await; + let env_b = app.create_environment(true).await; + sqlx::query("UPDATE environments SET subscriber_hmac_secret = $1 WHERE id = $2") + .bind(&app.env.hmac_secret) + .bind(env_b.id) + .execute(&app.pool) + .await + .expect("give env B env A's secret"); + + let sub = "usr_dump_restore"; + let hash_a = env_bound_hash( + &app.env.hmac_secret, + &ids::typeid(ids::ENVIRONMENT, app.env.id), + sub, + ); + + // Control: the env-bound hash is a live credential in env A. + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&app.env.slug, sub, &hash_a)) + .send() + .await + .expect("env A control"); + assert_eq!(res.status(), 200, "the env-bound hash is valid in env A"); + + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&env_b.slug, sub, &hash_a)) + .send() + .await + .expect("cross-env replay with shared secret"); + assert_eq!( + res.status(), + 401, + "env B must reject env A's hash despite the shared secret" + ); + let body: serde_json::Value = res.json().await.expect("error body"); + assert_eq!(body["error"]["code"], "unauthorized"); + + let env_b_subscribers: i64 = + sqlx::query_scalar("SELECT count(*) FROM subscribers WHERE environment_id = $1") + .bind(env_b.id) + .fetch_one(&app.pool) + .await + .expect("subscriber count"); + assert_eq!( + env_b_subscribers, 0, + "a rejected replay must not create a subscriber in env B" + ); +} + +/// Issue #55 dual-accept window. A legacy-formula hash MACs subscriber_id +/// alone and carries no environment binding, so with a shared secret it also +/// authenticates in env B. This is the accepted tradeoff of the rollout, +/// documented in the auth guide's legacy-formula callout. This test flips to +/// 401 when the fallback is dropped at the announced minor version bump. +#[tokio::test] +async fn shared_secret_legacy_hash_cross_authenticates_until_removal() { + let app = support::spawn().await; + let env_b = app.create_environment(true).await; + sqlx::query("UPDATE environments SET subscriber_hmac_secret = $1 WHERE id = $2") + .bind(&app.env.hmac_secret) + .bind(env_b.id) + .execute(&app.pool) + .await + .expect("give env B env A's secret"); + + let sub = "usr_dump_restore_legacy"; + let legacy = { + use hmac::Mac; + let mut mac = hmac::Hmac::::new_from_slice(app.env.hmac_secret.as_bytes()) + .expect("hmac accepts any key length"); + mac.update(sub.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + }; + + let res = app + .client + .get(format!("{}/v1/inbox/items", app.base)) + .headers(subscriber_headers_with_hash(&env_b.slug, sub, &legacy)) + .send() + .await + .expect("legacy cross-env probe"); + assert_eq!( + res.status(), + 200, + "legacy hashes cross-authenticate while the dual-accept fallback is live" + ); +} + /// A valid (subscriber_id, subscriber_hash) pair minted with env A's secret /// is rejected when presented under env B's slug. Auth resolves the secret /// from the presented slug, so env B can never verify env A's hash. The @@ -142,7 +274,7 @@ async fn env_a_subscriber_hash_replayed_against_env_b_is_401() { let app = support::spawn().await; let env_b = app.create_environment(true).await; let sub = "usr_replay"; - let hash_a = compute_subscriber_hash(&app.env.hmac_secret, sub); + let hash_a = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, sub); // Control: the pair is a live credential in env A. let res = app diff --git a/server/tests/sse.rs b/server/tests/sse.rs index f390753..bdf0b2f 100644 --- a/server/tests/sse.rs +++ b/server/tests/sse.rs @@ -235,7 +235,7 @@ async fn subscriber_hash_is_scrubbed_from_access_logs() { .expect("install capture subscriber"); let app = support::spawn().await; - let hash = chimely::auth::compute_subscriber_hash(&app.env.hmac_secret, SUB); + let hash = chimely::auth::compute_subscriber_hash(&app.env.hmac_secret, app.env.id, SUB); let mut stream = SseStream::connect(&app, SUB, None).await; // Force the response (and its access-log line) to materialize. stream.next_frame(Duration::from_secs(2)).await; diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs index 3e7e36b..f180b2f 100644 --- a/server/tests/support/mod.rs +++ b/server/tests/support/mod.rs @@ -386,7 +386,12 @@ impl TestApp { ); headers.insert( "X-Chimely-Subscriber-Hash", - HeaderValue::from_str(&compute_subscriber_hash(&env.hmac_secret, subscriber)).unwrap(), + HeaderValue::from_str(&compute_subscriber_hash( + &env.hmac_secret, + env.id, + subscriber, + )) + .unwrap(), ); headers } @@ -762,7 +767,7 @@ impl SseStream { subscriber: &str, last_event_id: Option<&str>, ) -> Self { - let hash = compute_subscriber_hash(&app.env.hmac_secret, subscriber); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, subscriber); let url = format!( "{base}/v1/inbox/stream?environment={}&subscriber_id={subscriber}&subscriber_hash={hash}", app.env.slug, @@ -780,7 +785,7 @@ impl SseStream { } pub async fn try_connect(app: &TestApp, subscriber: &str) -> reqwest::Response { - let hash = compute_subscriber_hash(&app.env.hmac_secret, subscriber); + let hash = compute_subscriber_hash(&app.env.hmac_secret, app.env.id, subscriber); let url = format!( "{}/v1/inbox/stream?environment={}&subscriber_id={subscriber}&subscriber_hash={hash}", app.base, app.env.slug,