Status · 2026-06-22 · Auth PROVEN in sandbox · Azure security-hardened (9 fixes live 2026-06-11)

Magic-Link Portal Login

One tap on a refill-reminder text or email and the patient lands in the refill wizard already logged in — no DOB entry, no verification code. Azure runs the bridge; Salesforce serves the wizard. The mint → redeem → JWT-bridge auth is now proven end-to-end in the sandbox; the storefront itself only renders in production.

Auth proven E2E · storefront render is prod-only · SFMC gated Azure-led One-click UX 72-hour TTL Security audit ✓ 2026-06-11
~1
Salesforce metadata delivery
External Client App (+ maybe 1 permset)
72 h
Link lifetime
Multi-use within window
~$250 / mo
Estimated run cost
Front Door Standard + EP1

NowWhere we are — 2026-06-22

Azure platform built, deployed, security-hardened (2026-06-11), and re-verified live (2026-06-22). Auth proven end-to-end in the sandbox. Salesforce-prod go-live not yet started; SFMC real-send gated on credential rotation.

✓ Live
Azure platform
Function App, Front Door + WAF, SQL, Key Vault, mint / redeem / health — all deployed to prod. Re-verified 2026-06-22: Running, HTTPS-only, SCM + FTP basic-auth disabled.
✓ Proven
End-to-end auth (sandbox)
Mint → redeem → JWT-Bearer / singleaccess bridge. SF LoginHistory 4/4 success from NAT IP 135.119.140.149, zero failures (2026-05-26).
⚠ Open · prod-only
Storefront render
Redeemed link authenticates but lands on the storefront login — the sandbox has no WebStore / BuyerGroup / Buyer Account. Decision: Option C — renders in prod, where the commerce data lives. Not an Azure defect.
⏳ Not started
Salesforce-prod go-live
Stand up the prod ECA + cert bind + consumer key, revert 3 App Config endpoints + bump the sentinel, prod smoke test. Not gated. See §8.
⛔ Gated
SFMC real-send
Blocked on Marissa's SFMC credential rotation. Stage the data extension + AMPScript in the SFMC sandbox until then.
Deferred
CDC / cache-miss fallback
Patient-Portal-V2 is the active priority (Kyle, 2026-06-11). Portal-user freshness gap accepted while SFMC send is gated.

Security audit — 9 findings fixed + deployed 2026-06-11 · PR #215

One live exposure + eight landmines. Adversarially verified; build + 11 tests green. State tags: live deployed to prod · repo / decided shipped in code or a recorded decision.

SCM/Kudu basic-auth disabled live
The one live exposure — internet-reachable basic-auth on the JWT-signing app. Disabled on scm + ftp (live + Bicep); config-zip still deploys via AAD. Re-verified allow=false 2026-06-22.
usp_CreateMagicLink idempotency revive live
Re-mint of the same (contactId, idempotencyKey) after expiry/auto-revoke no longer hits a UNIQUE(ShortCodeHash) 500 — the proc now matches in any state and revives the row; 2627/2601 → THROW 50409.
Pepper-rotation replay safety live
Proc returns HashPepperVersion; mint regenerates the short URL with the row's stored pepper version on replay. Rotation no longer breaks live links. Unit tests added.
/healthz WAF rate-limit + pepper cache live
20/min/IP rule added to both Front Door WAF policies + pepper provider cached, closing a Key-Vault-throttle DoS on the shared platform vault. Applied surgically.
Scanner gating decision decided
Keep recording ScannerSuspected for observability; do not gate the bridge — correct for 72h multi-use links (a false positive would block a real patient). No behavioral change.
Go-live App Config revert needs a sentinel bump doc
Refresh watches only MagicLink:Settings:Sentinel; reverting the endpoint values alone won't propagate. Runbook now says bump the sentinel (or restart) at cutover.
Bicep endpoint-key de-seeding repo
A redeploy had silently reverted the 3 Salesforce env-pointer keys to prod defaults. They're now operator-managed App Config (removed from Bicep-seeded keys).
ADF SyncControl bookkeeping repo
Sync_SFDC_PortalUser_Full now records SetSyncInProgress / UpdateSyncSuccess + an idempotent bootstrap row (activates with Phase 2 portal-user sync).
SfmcTokenValidator OIDC caching repo
The OIDC ConfigurationManager is cached per metadata address (it was rebuilt per request) — the validator is a DI singleton.

§1The feature, in one paragraph

When SFMC sends a refill-reminder text or email, the message carries a short URL. The patient taps it once and lands inside the Salesforce refill wizard already logged in — no DOB lookup, no verification code, no password. Three systems each do one thing: SFMC handles the send (hooks into the existing refill-reminder pipeline — no new flow); Azure mints the link, holds the signing key, and runs the OAuth handshake at click time; Salesforce establishes the session and serves the wizard.

The bulk of the build lives in the existing AdvancedRx Azure VNet — the same one that already hosts eFax, the e-script integration, and the patient-matcher service. The Salesforce footprint stays small; §4 lists exactly what's required there.

§2How the bridge works

SFMC sends the message; Azure mints the link, holds the signing key, and runs the OAuth handshake; Salesforce establishes the session and serves the wizard. Three systems, one job each.

Send
SFMC
Refill reminder
SMS or email
Bridge
Azure Function
Mint · Sign JWT · Exchange
VNet · Key Vault · SQL
Session
Salesforce
singleaccess
→ Refill wizard

Step by step

Salesforce decides it's time to remind
Salesforce owns the timing, patient, and channel: a Refill_Reminder__c scheduled-path flow fires at the record's Send_Date_Time__c, and JBSystemFlow_Refill_Reminder_c (a Salesforce flow, not SFMC) fires the bulk event that enters the SFMC journey. SFMC's job from here is render + deliver.
SFMC asks Azure for a link
AMPScript first fetches an Entra OAuth token (client_credentials), then HTTPPost2 to the machine-only mint host https://mint.advancedrx.dev/api/magic-links with the patient's Contact Id and portal User Id, channel, retPath, and an idempotency key.
Azure mints + stores
Generates a short code, stores the HMAC hash in SQL with a 72-hour TTL, returns { shortUrl, expiresAtUtc } to SFMC.
SFMC sends the message
The text or email goes out with the returned patient-facing link https://login.advancedrx.net/r/Kj3pQ9xMv7nR (a separate host from the machine-only mint endpoint).
Patient taps the link
Browser hits Azure (one tap, no interstitial — see §5). Azure looks up the hashed code, resolves the stored User Id → Salesforce username via the local cache.
Azure signs the Salesforce JWT
ECA private key never leaves Key Vault — CryptographyClient.SignAsync handles the signature in-vault.
JWT Bearer → access token → frontdoor
Azure POSTs the assertion to Salesforce's token endpoint, gets an access token, swaps it at /singleaccess for a one-time-use redirect URL.
Patient lands logged in
303 redirect to the Salesforce frontdoor URI; Salesforce establishes the session and serves the refill wizard. Total time: under a second.

§3Why Azure

AdvancedRx already operates a HIPAA-compliant Azure VNet for eFax, e-script, and patient-matcher. Reusing it for magic-link gives wins that are hard to get inside Salesforce.

§4Salesforce-side surface

Because the link expires by time rather than by event, the Salesforce footprint is small and well-bounded. It's fully built + proven in the sandbox (the ECA, cert binding, and demo patient all verified 2026-05-26); production go-live repeats it against the prod org (§8). Two items max:

1
External Client App — the trust record
Holds the Azure-Key-Vault-generated public signing cert, the OAuth scopes Azure can request, the AdminApprovedPreAuthorized permitted-users policy bound via SetupEntityAccess to a small permset, and IP relaxation (Bypass; clicks come from arbitrary patient IPs). Five metadata files: ECA, OAuth Settings, Global OAuth Settings, Configurable Policies, Certificate. Cert binding: the Key Vault keypair must exist first (Phase 1); then the public cert is bound to the ECA inline as base64 in GlobalOauthSettings.<certificate>, deployed via the metadata API — no JKS, no Setup-UI import. Salesforce holds the public cert only; Azure signs with the non-exportable Key Vault key. (Bound + verified in ClaudeTest 2026-05-26.) Full procedure in cert-rotation-runbook.md. Once created, the ECA's consumer key is handed back to Azure as App Config MagicLink:Salesforce:EcaClientId — Azure redeem is non-functional until that's set.
+0 or 1
Permission set for the ADF integration user (if needed)
Azure's nightly ADF job extends to mirror portal User records alongside the existing Contact cache. If the integration user already has User-read access, no change. If not, one small permset grants it. To confirm pre-build.

Explicitly not in scope on the Salesforce side: Apex, Named Credentials, flows, community-reachable endpoints, Custom Objects, Big Objects, LWC, public Experience Cloud routes, CMDT allowlists, AMPScript JWT minting.

§5Key design decisions

Three calls shape the architecture. Each was evaluated explicitly against alternatives; each is reversible if practice proves the trade wrong.

5.1 — Time-based expiry vs event-based
The link can either expire on a clock (time-based) or be killed the moment the patient submits a refill (event-based via a Salesforce → Azure webhook). Event-based gives tighter kill precision but requires Apex callout wiring, a Named Credential, an Azure endpoint, and tests on both sides.
✓ Chosen
Time only — 72-hour TTL
Link expires by clock, not by event. Bounded blast radius (refills of existing prescriptions to saved address only) accepted. Keeps the Salesforce-side surface to a single metadata record.
Deferred
Webhook on completion
Tighter kill precision (seconds, not hours). Engineering cost: one Apex class + Named Credential + Azure endpoint + trigger-event wiring + tests on both sides.
5.2 — One-click vs interstitial
Strictest posture: a "Tap to continue" interstitial page that blocks link scanners from triggering Salesforce sessions inside their sandboxes. Industry norm (Slack, Substack, Medium): one click, accept that scanners briefly hold ephemeral sessions.
✓ Chosen
One click — no interstitial
Matches Slack/Substack-grade UX. Scanner-induced sessions live only inside the scanner's ephemeral HTTP client and self-destruct. Audit log flags ScannerSuspected via UA signatures so triage stays clean.
Deferred
Explicit "Tap to continue"
Strictest scanner protection. One extra tap on the patient's side. Reversible to this design in a single-handler change if Salesforce-API volume from scanners proves material at rollout.
5.3 — Front Door tier: Standard vs Premium
Front Door Premium adds managed WAF rule sets, Bot Manager, and Private Link to origin — $295/mo more than Standard. For one narrow magic-link route, custom WAF rules + good function-side validation get most of the value.
✓ Chosen
Standard — ~$35/mo
Custom WAF rules + per-IP rate limiting + TLS + custom domain + edge logging. Sufficient for a single narrow public endpoint with strict input validation behind it.
Deferred
Premium — ~$330/mo
Worth it if/when we put other public Azure surfaces behind the same edge (storefront, outbound fax intake, future patient-facing endpoints). Then $295/mo delta amortizes across the platform.

§6Two pre-build spikes (gates)

The architecture depends on two things working that aren't documented for our exact configuration. Both spikes are small, parallelizable, and gating — neither requires a single line of production code.

✓ Spike A · Salesforce · PASSED

JWT Bearer + singleaccess for CCP Login

Proved the full handshake works for a Customer Community Plus Login license user. Minted a JWT locally, exchanged at the community token endpoint (returned access_token + community URL), POSTed to /singleaccess (returned frontdoor_uri JSON), followed the redirect chain (sid session cookie set, HTTP 200 on the storefront page).
Verified 2026-05-18 in ClaudeTest · three pre-auth gotchas captured in plan.md Phase 0 + a Phase 4 SetupEntityAccess wiring step. A subsequent Phase 4 metadata dry-run (same sandbox) surfaced three more execution details — cert binding requires an imported public cert; ECA enum values differ from legacy ConnectedApp; SetupEntityAccess.SetupEntityType is platform-inferred not writable. All folded into plan.md §6 Phase 0 so the production build doesn't re-derive them.
✓ Spike B · SFMC · SOFT-PASSED

Link wrapping + fragment behavior

Production SFMC self-send delivered both fragment (`/r#…`) and path (`/r/…`) URLs to Gmail with the href values completely unmodified — no wrapping through click.advancedrx.email. Caveat: SFMC Test Sends often skip click-tracking link wrapping; real journey/triggered-send wrapping behavior deferred to Phase 6a A/B rollout for definitive verification. V2 design accommodates either outcome.
Verified 2026-05-21 via production self-send · four execution gotchas + AdvancedRx SFMC infrastructure facts captured in plan.md.

§7Cost

Order-of-magnitude monthly estimate at expected refill-reminder volume. SQL adds are near-zero (reuses existing DB); Key Vault and App Insights are small even at scale.

Front Door Standard
Custom WAF rules + TLS + custom domain + edge logging
~$35
Function App EP1
Dedicated, always-warm, isolated blast radius from eFax
~$150–250
SQL · reuse existing
Two new tables on existing DB
~$0
Key Vault signing ops
Per-click sign call; volume-priced
~$5
App Insights
Sampled + scrubbed
~$5–25
Total · estimated
Realistic v1 range
~$200–315 / mo

§8What's next

The auth flow is proven end-to-end in the sandbox and the Azure plane was security-hardened on 2026-06-11 (see "Where we are now"). What remains is the Salesforce-prod go-live plus the one open storefront-render gap (prod-only data, not a flow defect).

If the prod CCP Login + singleaccess exchange fails, an alternative Salesforce-only architecture is available as a fallback design record at ../magic-link-portal-login.md.

§9Verification basis

The architecture is reviewed against the existing Azure infrastructure repo and the live Salesforce + SFMC landscape.

Security hardening (2026-06-11, PR #215): a fresh-eyes audit fixed one live exposure (SCM basic-auth) + eight landmines — all 9 deployed live or in repo; adversarially verified, build + 11 tests green. Per-finding list under "Where we are now."

Live re-verify (2026-06-22): fn-magiclink-prod-east2 Running + HTTPS-only; SCM and FTP basic-publishing-credentials both allow=false (finding #1 confirmed live). App Config endpoint values are readable only from inside the VNet (jumpbox) — confirm / revert at go-live.

End-to-end auth (2026-05-26): the full mint → redeem → JWT-Bearer/singleaccess bridge was exercised in ClaudeTest against the demo portal user. Proof on both sides: Azure SQL Bridge_Success ×2 / RedeemCount=2 with zero failures, and Salesforce LoginHistory showing 4/4 successful logins, all from the Azure NAT IP 135.119.140.149 (2 Magic_Link_Bridge ECA token exchanges + 2 storefront session handoffs), with zero human/impersonation logins — the JWT bridge is fully exonerated. The only post-auth failure is the storefront render (prod-only commerce data; §8 + plan §10 Risk #19).

Azure side (as-built): the orchestration plane is BUILT and deployed (sandbox build) in advancedrx-document-integration. The mint contract in §7.1 is reconciled directly against the source (MintMagicLinkFunction.cs) — confirming mint requires both Contact Id and portal User Id, returns { shortUrl, expiresAtUtc }, and authenticates via Entra OAuth client credentials. Split hosts: machine-only mint.advancedrx.dev + patient login.advancedrx.net, both fronted by Azure Front Door. As-built facts in plan.md §4.6.

Salesforce side: aligns with storefront-data-audit.md (B2B storefront serving at https://advancedrx.net/vforcesite/ on the CCP Login license profile), integrations/sfmc.md (the existing refill-reminder send pipeline at JBSystemFlow_Refill_Reminder_c), and the in-progress SFMC credential rotation per MARISSA-HANDOFF.md §2 #2.

Spike A: CCP Login + JWT Bearer + singleaccess verified end-to-end in ClaudeTest 2026-05-18 — full handshake (token endpoint → singleaccess → frontdoor redirect → sid cookie set) confirmed for the CCP Login license.

Spike B: Production SFMC self-send 2026-05-21 delivered both fragment + path URLs to Gmail unmodified. Caveat: Test Sends often skip click-tracking link wrapping; real journey-send wrapping behavior verifies during Phase 6a A/B rollout. AdvancedRx SFMC infrastructure facts (sending stack 11, From domain hello@advancedrx.email, vanity click-tracking host click.advancedrx.email, dual-DKIM signed, DMARC pass) captured in plan.md §4.5.

Phase 4 metadata dry-run: the Salesforce-side metadata bundle (ECA, permset, post-deploy Apex) was authored and exercised end-to-end against the ClaudeTest sandbox on 2026-05-21 to validate the deployable shape before the production build. Three execution gotchas surfaced — cert binding requires an imported public cert (not a Salesforce-generated one); ECA policy enums differ from legacy ConnectedApp (AdminApprovedPreAuthorized + Bypass); SetupEntityAccess.SetupEntityType is platform-inferred, not writable via Apex. All three folded into plan.md §6 Phase 0 + §7.6 + the Phase 4 build steps so the production deploy doesn't re-derive them. The sandbox artifacts are throwaway; production deploy is still pending. Cert-import procedure: cert-rotation-runbook.md.