Status · 2026-08-24 · FULL end-to-end PROVEN in sandbox · Mint caller BUILT + eligibility-gated (Salesforce core) · Sandbox portal-user cache deployed · Enhancement §§1–2 shipped to production 2026-08-11/13 · All Salesforce-side decisions closed

Magic-Link Portal Login

One tap on a refill-reminder text and the patient lands on their My Home page already logged in — no date of birth to enter, no verification code to fetch. Email works differently since [D16] (2026-08-06): emailed links land on a date-of-birth step-up before the login runs, so the tap alone no longer completes it. Salesforce mints the link at dispatch time; Azure runs the bridge; SFMC renders it as a merge field. As of 2026-08-20 the whole journey is proven in the sandbox — Salesforce minted the link, a test patient clicked it, and they landed logged in on /my-home with the refill wizard available. Nothing was hand-seeded, and a replay of the same link after the old database grant was withdrawn worked too. What remains is production: the bridge app, the permission grant for every existing patient, the config cutover, and the reminder-flow wiring.

Full journey proven E2E in sandbox · mint caller built + gated · sandbox cache live Azure-led No SFMC-held secret One-click on SMS · DOB step-up on email 72-hour TTL Jun 2026 audit ✓ · Jul review found 4 (1 Critical) · §§1–2 shipped Aug 2026
1 + 1
Salesforce deliveries
Bridge ECA (proven) + mint caller (built + proven + eligibility-gated)
72 h
Link lifetime
Multi-use within window
~$250 / mo
Estimated run cost
Front Door Standard + EP1

NowWhere we are — 2026-07-08

Azure platform built, deployed, security-hardened (2026-06-11), and re-verified live (2026-06-22). Auth proven end-to-end in the sandbox. Direction change 2026-07-08: the mint caller moves from SFMC to Salesforce core — the send path is no longer gated on the SFMC credential rotation; the new open work is the Salesforce-side mint caller (design + build).

✓ 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).
✅ Closed 2026-08-18
Storefront render
The May gap was missing commerce data in the sandbox, nothing else: once the store was stood up, a logged-in test patient lands on the portal home page and the refill wizard opens. Proven 2026-08-18.
⏳ 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.
⏳ Not started · new
Salesforce mint caller (2026-07-08)
Direction change: Salesforce core mints via invocable + Named Credential; SFMC becomes merge-field-only. Replaces the AMPScript mint wiring outright — no longer gated on the credential rotation. Design + build pending (see §8).
Deferred
CDC / cache-miss fallback
Patient-Portal-V2 is the active priority (Kyle, 2026-06-11). Portal-user freshness gap accepted until the send path ships.

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.

Direction change — who asks for the link

Committed 2026-07-08 (plan.md revision 11; authoritative decision record in the doc-integration enhancement plan §6): the mint request originates from Salesforce core, not the Marketing Cloud journey. Azure is unchanged — it still generates, stores, redeems, and bridges the link; only the prompter changes. Nothing gets unwound: the SFMC mint wiring was never built and its Entra secret was never handed to Marketing Cloud.

Superseded · never built

SFMC-initiated mint

  • AMPScript fetches an Entra token, then HTTPPost2 to mint at render time
  • Entra client secret copied into an SFMC-side store
  • Eligibility exported to DE columns + journey decision splits
  • Send path gated on the SFMC credential rotation (Risk #3)
Committed 2026-07-08

Salesforce-core-initiated mint

  • Apex invocable behind a Named Credential mints at reminder-dispatch time
  • Secret lives in a Salesforce External Credential — no SFMC-held secret
  • The data extension carries the pre-minted shortUrl; SFMC renders a merge field
  • Magic-link path no longer gated on the rotation

Why

New Salesforce-side design work it creates

§1The feature, in one paragraph

When a refill-reminder text or email goes out, the message carries a short URL. The patient taps it once and lands on their My Home page in the Salesforce portal already logged in — no date of birth to enter, no verification code to fetch. What it replaces: the normal portal login is email address + date of birth, then a one-time code sent to that address. There is no password at any point, so nothing here is "skipping a password" — a phrasing that has appeared in several documents and is wrong. On the email channel, [D16] (2026-08-06) adds a date-of-birth step-up before the login runs, so there the tap alone no longer completes it. Three systems each do one thing: Salesforce decides who gets reminded and asks Azure for the link at dispatch time (the 2026-07-08 direction change); Azure generates the link, holds the signing key, and runs the OAuth handshake at click time; SFMC renders the pre-minted URL as a merge field and delivers the message. Salesforce then 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

Salesforce asks for the link and fires the journey event; SFMC renders and delivers the message; Azure mints, holds the signing key, and runs the OAuth handshake at click time; Salesforce establishes the session and serves the wizard.

Decide + Mint
Salesforce
Reminder flow mints via
invocable + Named Credential
Send
SFMC
Merge-field render
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. Under the 2026-07-08 direction it also mints the link before firing that event — SFMC's job from here is render + deliver.
Salesforce asks Azure for a link
An @InvocableMethod Apex action behind a Named Credential (Entra client credentials as MagicLink-Salesforce-Client) POSTs to the machine-only mint host https://mint.advancedrx.dev/api/magic-links with the patient's Contact Id and portal User Id (read directly from Contact.Portal_User__c), channel, retPath, and an idempotency key (the reminder Id).
Azure mints + stores
Generates a short code, stores the HMAC hash in SQL with a 72-hour TTL, returns { shortUrl, expiresAtUtc } to Salesforce.
SFMC renders + sends the message
The pre-minted shortUrl rides the journey-event payload into the data extension and the template renders it as a merge field — no AMPScript API call. The message goes out with the patient-facing link https://login.advancedrx.net/r/Kj3pQ9xMv7nR (a separate host from the machine-only mint endpoint). Blank URL (mint failed) → the plain reminder still sends — fail-open.
Patient taps the link
Browser hits Azure (one tap on SMS, no interstitial; emailed links stop at the [D16] date-of-birth step-up first — 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 My Home. 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

The bridge-side footprint is small and proven: the ECA, cert binding, and demo patient were all verified in the sandbox 2026-05-26; production go-live repeats it against the prod org (§8). The 2026-07-08 direction change added one new delivery — the mint caller, built 2026-08-19 and eligibility-gated 2026-08-24:

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.
+1
Mint caller — new under the 2026-07-08 direction change
BUILT: External Credential (Entra client credentials as MagicLink-Salesforce-Client) + Named Credential Magic_Link_Mint + MagicLinkMintInvocable (13/13 tests). The contract requires an eligibility verdict (2026-08-24): calls without one are refused and logged (RefusedNoVerdict); an ineligible patient is skipped quietly (SkippedIneligible). Since revision 23, Magic_Link__c is the mint-attempt record — every outcome upserts a row carrying Mint_Status__c, with the URL written on Minted only, so a new channel needs no new fields and one query covers every lane. The URL stays FLS-gated to Magic_Link_View_All and auto-blanked ~1 h per [D4]. Fails open per [D2-am] to B2B_Custom_Exception__c; batch of 2 is the [D2] tripwire. Remaining: the reminder-flow wiring itself — no longer [D22]-gated, since D22 was deferred to v2 on 2026-08-25 [D22-am].

Explicitly not in scope on the Salesforce side: community-reachable endpoints, Custom Objects, Big Objects, LWC, public Experience Cloud routes, CMDT allowlists, AMPScript of any kind (the 2026-07-08 direction change removed the last SFMC API call). Apex + a Named Credential are now in scope — the one addition the direction change brought onto the Salesforce side.

§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.

Direction change (2026-07-08): the mint caller moved from SFMC to Salesforce core — decision record in the doc-integration enhancement plan §6 and plan.md's Direction change section (revision 11). The Azure mint contract itself is unchanged and re-verified against MintMagicLinkFunction.cs (requires contactId + userId + channel + retPath + idempotencyKey; returns { shortUrl, expiresAtUtc }); the Azure-side change is allowlist-config-only. The SFMC mint wiring was never built and the MagicLink-SFMC-Client secret was never distributed, so nothing gets unwound.