Skip to content

feat: attach identity verification token to push subscription requests - #735

Merged
ioannisj merged 12 commits into
feat/push-notificationsfrom
feat/push-notifications-identity
Jul 29, 2026
Merged

feat: attach identity verification token to push subscription requests#735
ioannisj merged 12 commits into
feat/push-notificationsfrom
feat/push-notifications-identity

Conversation

@ioannisj

@ioannisj ioannisj commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

PostHog/posthog#72488 (draft) adds opt-in identity verification to /api/push_subscriptions: the backend mints a short-lived HS256 JWT (sub = distinct_id, app_id, aud = posthog:push_identity, exp) signed with the project's secret API key, and verifies it on POST and DELETE. Integration modes are disabled (default), optional, required. Without SDK support there is no way to attach the token, so required mode is unusable from iOS. The provider shape was agreed with Dan in #team-messaging.

Adds the SDK half:

  • New config.pushIdentityProvider: (distinctId, appId, completion) -> Void. The app hands the backend-minted token (or nil) to completion, from any thread, exactly once.
  • The token goes out as identity_token on the register POST and unregister DELETE. When the key is absent it is omitted entirely, so the body stays byte-identical to today's contract for apps that don't opt in.
  • Minting is on demand and cached in memory per exact (distinctId, appId): reused across in-session backoff retries, re-minted on identity change, refreshed on 401, and never cached once minted after opt-out.

💚 How did you test it?

make test green (707 tests), including cross-SDK vectors 9-14 (token on both legs, key omission when absent, reset-leg token counts, 500-retry cache reuse, 401 refresh then terminal, 401 with no provider) and exactly-once foreign-thread completion tests. make apiCheck and make lint clean.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change entry to add to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate the changeset file (deliberately omitted: feature branch, not releasing yet)

🤖 Agent context

DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)

@ioannisj
ioannisj requested a review from a team as a code owner July 27, 2026 09:49
@ioannisj ioannisj self-assigned this Jul 27, 2026
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift:328-330
**Late mint restores invalidated cache**

When the app opts out while an asynchronous provider invocation is outstanding, the completion stores its token after `onOptOut()` has cleared the cache and before the allowed-state guard runs. A later opt-in for the same identity and app therefore reuses the opt-out-era token instead of re-minting, causing an unnecessary rejected registration when that token has expired.

Reviews (1): Last reviewed commit: "feat: attach identity verification token..." | Re-trigger Greptile

Comment thread PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift Outdated
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-07-29 14:34:34 UTC
Duration: 223618ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 3657ms
Format Validation.Event Has Uuid 2698ms
Format Validation.Event Has Lib Properties 384ms
Format Validation.Distinct Id Is String 2740ms
Format Validation.Token Is Present 2720ms
Format Validation.Custom Properties Preserved 2754ms
Format Validation.Event Has Timestamp 2743ms
Retry Behavior.Retries On 503 11178ms
Retry Behavior.Does Not Retry On 400 4806ms
Retry Behavior.Does Not Retry On 401 4664ms
Retry Behavior.Respects Retry After Header 7676ms
Retry Behavior.Implements Backoff 19219ms
Retry Behavior.Retries On 500 9149ms
Retry Behavior.Retries On 502 9315ms
Retry Behavior.Retries On 504 9224ms
Retry Behavior.Max Retries Respected 21310ms
Deduplication.Generates Unique Uuids 2958ms
Deduplication.Preserves Uuid On Retry 5281ms
Deduplication.Preserves Uuid And Timestamp On Retry 16288ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7794ms
Deduplication.No Duplicate Events In Batch 2825ms
Deduplication.Different Events Have Different Uuids 2686ms
Compression.Sends Gzip When Enabled 2741ms
Batch Format.Uses Proper Batch Structure 2736ms
Batch Format.Flush With No Events Sends Nothing 271ms
Batch Format.Multiple Events Batched Together 2832ms
Error Handling.Does Not Retry On 403 4754ms
Error Handling.Does Not Retry On 413 4715ms
Error Handling.Retries On 408 9140ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2757ms
Request Payload.Flags Request Uses V2 Query Param 2646ms
Request Payload.Flags Request Hits Flags Path Not Decide 2777ms
Request Payload.Flags Request Omits Authorization Header 2679ms
Request Payload.Token In Flags Body Matches Init 2718ms
Request Payload.Groups Round Trip 2713ms
Request Payload.Groups Default To Empty Object 2736ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2782ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2747ms
Request Payload.Disable Geoip Omitted Defaults To False 2695ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2672ms
Request Lifecycle.No Flags Request On Init Alone 55ms
Request Lifecycle.No Flags Request On Normal Capture 2711ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5443ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2768ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2822ms

@ioannisj
ioannisj marked this pull request as draft July 27, 2026 10:27
@ioannisj
ioannisj marked this pull request as ready for review July 28, 2026 11:16
Comment thread PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift Outdated
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift:348-355
**Opt-out cache restoration race**

When opt-out occurs after this completion samples `isAllowedProvider()` as true but before it acquires `stateLock`, `onOptOut()` clears the cache and this block immediately writes the old token back using the stale `allowed` value. A later opt-in for the same distinct ID and app ID then takes the cache-hit path without invoking the provider, causing registration to reuse an expired or revoked token and receive an avoidable 401.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (2): Last reviewed commit: "fix: harden async push identity token mi..." | Re-trigger Greptile

@posthog

posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 3 should fix, 0 consider.

Published 3 findings (view the review).

@posthog

posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Feature

Issues: 3 issues

Files (4)
  • PostHog/PostHogApi.swift
  • PostHog/PostHogConfig.swift
  • PostHog/PostHogSDK.swift
  • PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift
What were the main changes
  • Add config.pushIdentityProvider hook allowing apps to supply a backend-minted identity token, invoked with (distinctId, appId, completion)
  • PostHogApi POST/DELETE push subscription calls now accept an identityToken and serialize it as identity_token, omitting the key entirely when absent to preserve backward compatibility
  • PostHogPushSubscriptionHandler mints/caches identity tokens per exact (distinctId, appId), reusing across retries, re-minting on identity change, and refreshing once on a 401 response
  • Handles opt-out races: onOptOut() clears cached token and pending 401-retry flag under stateLock to prevent stale-token resurrection; mid-mint opt-out aborts pending resend
  • Adds a bounded mint timeout so a misbehaving provider that never calls completion falls back to a token-less send instead of wedging the send pipeline
  • PostHogSDK now calls pushSubscriptionHandler?.onOptOut() when the user opts out

Other findings (outside the changed lines)

Valid issues on this PR's files that sit on lines GitHub won't let us comment on inline.

Coalesced resend clears didAuthRetry mid-flight, defeating the one-retry-per-401 cap

Priority: should_fix | File: PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift:249-256, 378-422 | Category: bug

Why we think it's a valid issue
  • Checked: Traced the full interleaving across handleResult (378-400), handleFailure's 401 branch (404-421), attemptIfAllowed's in-flight fold (276-291), and resetRetryState (249-256).
  • Found: handleResult sets isSending = false at line 380 before dispatching to handleFailure, so the R2 send that handleFailure starts (line 418) genuinely claims isSending = true and returns while its mint+POST is async in flight. Control then reaches the hadPendingResend branch (396-399), whose unconditional resetRetryState() sets didAuthRetry = false (line 254) out from under the in-flight R2 — the follow-up attemptIfAllowed merely folds into pendingResend = true (isSending already true), so it clears the cap without starting anything new. Confirmed the reviewer's mechanism.
  • Found: No backstop bounds the resulting loop: the 401-fresh-token path returns at line 419 before any pausedUntil is set (455) or halted marked (432/449), and retryCount is never incremented on it. Worse than a single extra retry — the hadPendingResend follow-up itself re-arms pendingResend = true each cycle, so with the server persistently 401ing the state at cycle end equals cycle start (pendingResend=true, didAuthRetry=false, next send in flight): a self-sustaining, backoff-free mint+POST loop.
  • Impact: A documented invariant ('one fresh-token retry per send-cycle', 63-64/405-406) is violated, and the intended terminal 'rejected (401)' halt is replaced by an unbounded loop of provider mints and network POSTs. Trigger is realistic in required identity mode: a mid-flight identity change (or new registration) coalescing into pendingResend while the original send 401s — the very case that also makes the retried token likely to 401 again. Correctness + reliability defect with a concrete trigger and concrete consequence; meets the keep bar. should_fix is appropriate given the entry condition is a specific interleaving rather than a common path.
Issue description

handleFailure's new 401 fresh-token retry (lines 404-421) sets didAuthRetry = true and calls attemptIfAllowed to start a re-mint-and-resend (call it R2) before returning. Because minting is asynchronous, attemptIfAllowed returns immediately after only kicking off R2's mint. Control then returns synchronously to handleResult (lines 378-401): if a registration/identity-change request had coalesced into pendingResend while the original request (R1) was in flight, handleResult's if hadPendingResend, let record = loadRecord() { resetRetryState(); attemptIfAllowed(...) } branch (lines 396-399) unconditionally runs resetRetryState(), which resets didAuthRetry back to false (line 254) even though R2 (the auth-retry just started) is still in flight and relies on didAuthRetry == true to enforce the documented 'one fresh-token retry per send-cycle' cap. Since isSending is already true from R2, this second attemptIfAllowed call doesn't start a new request - it just re-sets pendingResend = true - but it still wipes didAuthRetry. If R2 itself later also returns 401 (plausible, since the very thing that set pendingResend was often an identity change that made the original token stale), handleFailure sees didAuthRetry == false and grants a SECOND fresh-token retry, and the same coalescing pattern can repeat, defeating the intended one-retry-per-cycle guarantee and causing extra mint/network round-trips instead of the intended terminal 'Push subscription rejected (401)' halt.

Suggested fix

Don't let the pendingResend follow-up in handleResult clobber the retry state of an auth-retry that handleFailure just armed for the same cycle. For example, have handleFailure return whether it re-armed a send (i.e. whether shouldRetryWithFreshToken was true) and, when it did, skip handleResult's own resetRetryState()+attemptIfAllowed() call for that completion (the just-started R2 will itself pick up the latest record via the normal in-flight-fold mechanism). Alternatively, only reset didAuthRetry when isSending is confirmed false at the time of the pendingResend follow-up, so a still-in-flight auth-retry's cap isn't cleared out from under it.

Comment thread PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift Outdated
Comment thread PostHog/PostHogConfig.swift Outdated

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registration path looks well covered, but the unregister path can leave a user's subscription active once the cached JWT expires. Also, the current head has red test checks (test and test-ios-simulator); the simulator job specifically reports the newly added vector-11 reset test failing.

} else {
hedgeLog("Push unregister failed (status \(info.statusCode.map(String.init) ?? "none")); ignoring (best-effort).")
// Same one-verification-state as registration: the DELETE resolves a token for the distinct id
// it carries (the old identity on the reset() path). Still best-effort — no 401 refresh here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Refresh expired tokens before giving up on unregister

resolveIdentityToken prefers an in-memory token with no expiry check, while the backend mints these with a 5-minute default TTL. A normal logout/reset later in the same process can therefore send an expired token here; required mode returns 401, this path deliberately does not refresh, and unregisterCurrentToken() has already deleted the local record. The old person's server-side subscription is then left active, so this device can keep receiving their notifications after logout. Please invalidate/remint and retry the DELETE once on 401 (or always mint fresh for DELETE) before dropping this best-effort operation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same data-loss path occurs when the device is offline: unlike registration, unregister does not check connectivity or persist a pending operation, and unregisterCurrentToken() removes the local record before attempting the DELETE. A logout/reset while offline therefore gets one transport failure, is never retried on flush() or next launch, and leaves the device associated with the old user on the backend. We should persist a separate pending-unregister intent and retry it later (or otherwise retain enough state to retry) rather than dropping it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in f1bd05e. Unregister now persists a pending intent (PendingUnregister) before the DELETE, so an offline or failed attempt gets retried on flush() and next launch instead of dropped. And on a 401 it clears the cached token and re-mints once before giving up, same as the send path.

Need to close the same gap on Android, will do on upcoming release PRs

@marandaneto
marandaneto dismissed their stale review July 29, 2026 07:42

reqeust for changes wasnt intentional

@ioannisj
ioannisj merged commit 55256ee into feat/push-notifications Jul 29, 2026
39 checks passed
@ioannisj
ioannisj deleted the feat/push-notifications-identity branch July 29, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants