# Magic-link portal login from refill-reminder SMS or email — Project document

> **Status:** Pre-implementation. This document is the lead-developer handoff.
> **Owner:** Kyle Salata (kyle@advancedrx.co).
> **Last updated:** 2026-04-27 (revision 2 — email channel in scope; multiple gap closures).
> **Read together with:** `CLAUDE.md`, `.claude/rules/apex.md`, `.claude/rules/flow.md`, `business-context/integrations/sfmc.md`, `business-context/storefront-data-audit.md`, `business-context/workflows/patient-texting-refill-reminders.md`, `business-context/objects/Refill.md`, `business-context/objects/Account_Sig.md`.

---

## §1 Executive summary

Patients receive refill-reminder messages today via SFMC — both **SMS via MobileConnect** and **email via OMM** — out of the same `JBSystemFlow_Refill_Reminder_c` flow (documented in `patient-texting-refill-reminders.md`). The friction across both channels is the same: patients have to find the portal URL, complete the existing custom login (DOB + email-verification-code via `B2BCustomLoginHandler` + `B2BCustomLoginHelper` + the `b2bDateOfBirthVerification` / `b2bLoginCodeComponent` LWCs — a two-step UX with an email round-trip, where successful DOB match triggers a code email that the patient enters on a follow-up screen, and where never-before-logged-in users see a deliberate first-attempt rejection by design), and navigate to the refill wizard. Drop-off is high. _(Friction story corrected 2026-04-27 — earlier framing said "username + password," which doesn't match the actual UX. The magic link still solves real friction; the friction is "DOB recall + reachable email + code-entry round-trip," not "remembering a password.")_

**What we're building.** A one-click magic link in the refill-reminder SMS _and_ email that authenticates the patient into the B2B Commerce storefront (`Advanced RX Store`) and drops them on the refill wizard. The patient never enters credentials.

**Mechanism.** OAuth 2.0 JWT Bearer Flow + Salesforce `singleaccess` endpoint, with a two-token design (10-minute custom token in the link, 3-minute Salesforce JWT assertion exchanged for a session at click time). Pattern is supported by official Salesforce building blocks (`Auth.JWT`, `Auth.JWS`, `Auth.JWTBearerTokenExchange`, `/services/oauth2/singleaccess`); Salesforce Identity team published a reference implementation.

**Compliance posture.** The org is HIPAA-regulated (USP 795 nonsterile compounding pharmacy, 48 states). Shield is **not** licensed, but Shield is not a hard requirement for this feature — audit retention can be served by Custom Object → Big Object archival. Compliance counsel sign-off is required before build starts (see §5).

**Scope.** SMS + email refill-reminder magic links, both from v1. Other use cases (B2B storefront login from non-SMS/non-email surfaces, SCV callback authentication, manually-issued admin links) are explicit non-goals for this project.

---

## §2 Use case

### Patient story — SMS

1. Sarah is a long-time AdvancedRx patient. Her prescription is due for refill.
2. SFMC MobileConnect sends Sarah an SMS: _"Hi Sarah, your prescription is ready to refill. Tap to start: https://refill.advancedrx.com/r/Xy3qP9. Reply STOP to opt out."_
3. Sarah taps. Her browser opens to a public Experience Cloud landing page that displays a "Logging you in..." spinner for ~1 second.
4. The page authenticates her in the background and redirects her to the refill wizard inside the portal. She's logged in. Her name is in the header.
5. She completes the refill in 2-3 taps.

### Patient story — email

1. Marcus prefers email. The same `JBSystemFlow_Refill_Reminder_c` flow that handles SMS for Sarah handles email for Marcus, gated by his communication preference.
2. SFMC sends Marcus a refill-reminder email rendered from an OMM template. The email body contains a "View Your Prescription" button. Underlying URL: `https://refill.advancedrx.com/r/Pk7zM2`.
3. Marcus taps the button on his phone or clicks it from desktop. Same public landing page, same bridge, same authenticated landing on the refill wizard.

### What's NOT in scope

- Magic links from non-SFMC surfaces (Service Cloud Voice, case-driven texting, manual emails from CSRs).
- New refill wizard logic. The existing wizard (built on `Account_Sig__c` transient scratch state — see `business-context/objects/Account_Sig.md`) is the landing target; we are not changing it. **Verification step in Phase 3** confirms wizard initialization is clean.
- Replacement for the existing login. The DOB + email-verification-code login on the storefront stays. Magic link is additive.
- Other patient-portal entry points (account settings, prescription history). The link redirects to the refill wizard specifically. Generalizing the redirect target is a phase-2 enhancement, gated by the §7.5 retURL allowlist CMDT.

---

## §3 Architecture

### §3.1 Two-token design — why

Salesforce enforces a **maximum 3-minute lifetime** on the `exp` claim of a JWT Bearer assertion. Industry data shows 67% of users click within 5 minutes and 89% within 15 minutes — a 3-minute window fails for most recipients. Email recipients click on a longer tail than SMS; the constraint is even more binding for email.

We solve this with two tokens:

- **Token 1 — Magic-link token.** Custom signed token (RS256). Generated at message-send time. Embedded in the link via a short-code indirection. Validity: **10 minutes**. Contains an opaque `sub` (mapping UUID, NOT the Salesforce user ID) + a `jti` for one-time-use enforcement. _Not_ a Salesforce-recognized JWT.
- **Token 2 — JWT Bearer assertion.** Salesforce-format JWT. Generated **at click time** by Apex on the landing page. Validity: 180 seconds (3-minute Salesforce maximum). Exchanged immediately for an access token, which is then bridged to a session via `singleaccess`.

### §3.2 End-to-end flow

The mint path differs by channel; the bridge path is channel-agnostic.

```
[SFMC — JBSystemFlow_Refill_Reminder_c]
    │
    │  Channel-specific send-time event fires:
    │   - SMS callsite (EventDefinitionKey SalesforceObj62357...) renders MobileConnect template
    │   - Email callsite (EventDefinitionKey SalesforceObj8e4d8a4f...) renders OMM email template
    │  Channel selection per the gate predicates documented in
    │  patient-texting-refill-reminders.md §4.1.
    │
    │  Both templates execute the same AMPScript pattern:
    │   1. Mint Token 1 via GetJWTByKeyName (RS256, key in SFMC Key Management).
    │   2. POST {token, retUrl, channel} to /svc/short-code on Salesforce
    │      (authenticated via SFMC Installed Package OAuth — see §7.8).
    │   3. Receive shortCode in JSON response.
    │   4. Render link as https://refill.advancedrx.com/r/{shortCode}.
    │
    ▼
[Patient device]
    │
    │  Receives SMS or opens email; taps link.
    │  ─── Channel-specific paths converge here ───
    ▼
[Public Experience Cloud landing page on Advanced RX Store]
    │
    │  magicLinkLanding LWC mounts. Reads {shortCode} from URL.
    │  Calls @AuraEnabled MagicLinkBridgeController.bridge(shortCode).
    ▼
[Apex — guest-user context]
    │
    │  1. Resolve shortCode → Token 1 via without-sharing helper (§7.6 OWD note).
    │  2. Detect prefetch (User-Agent + Accept-Language heuristic — §10 row 12).
    │     If prefetch: log Bridge_Prefetch_Skipped, do NOT consume jti, return 204.
    │  3. Validate Token 1: signature (RS256 against public key in SF CKM), exp, claim shape.
    │  4. Validate retUrl against Magic_Link_Allowed_Return_Path__mdt allowlist (defense in depth).
    │  5. Atomically mark jti consumed (Magic_Link_Token__c upsert by external ID).
    │  6. Mint Token 2 (Auth.JWT + Auth.JWS):
    │       iss = ECA Client ID
    │       sub = patient username (resolved from Magic_Link_Token__c.User__c)
    │       aud = community URL
    │       exp = now + 180s
    │  7. POST Token 2 to callout:SF_OAuth_Token
    │     grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
    │     Receive access_token (web scope).
    │  8. POST access_token to callout:SF_OAuth_SingleAccess
    │     Receive { "frontdoor_uri": "https://..." } in JSON.
    │  9. Insert Magic_Link_Auth_Event__c with Bridge_Success + IP + UA.
    ▼
[LWC]
    │
    │  Apex returns frontdoor_uri to LWC.
    │  LWC sets window.location to frontdoor_uri.
    ▼
[Salesforce frontdoor]
    │
    │  Salesforce establishes session, redirects to retUrl.
    ▼
[Refill wizard — patient is authenticated]
```

### §3.3 Why `singleaccess`, not raw `frontdoor.jsp`

Raw `frontdoor.jsp?sid=ACCESS_TOKEN&retURL=...` puts the access token in a URL query parameter. That URL ends up in browser history (often device-synced), proxy logs, HTTP `Referer` headers, and browser caches. For PHI-adjacent access, that's an unacceptable transmission-security exposure.

The `singleaccess` endpoint (Summer '24, Release 250) takes the access token in a POST body and returns a one-time-use `frontdoor_uri` in JSON. The token never sits in a URL. Salesforce Mobile SDK 12.2+ migrated to this endpoint — that's the strongest signal it's the recommended path.

**We will use `singleaccess` exclusively. Do not implement the legacy `frontdoor.jsp?sid=` pattern as a fallback.**

### §3.4 Why External Client App, not Connected App

Spring '26 disabled new Connected App creation by default. New work goes through External Client Apps (ECAs), which support the same JWT Bearer flow. Existing Connected Apps continue to work, but building new infrastructure on a deprecating feature isn't worth the rework cost.

---

## §4 Org current state — relevance to this project

### §4.1 What we have that helps

| Asset                                                                        | Relevance                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Marketing Cloud Connect (`et4ae5`, active)                                   | AMPScript `GetJWTByKeyName` (Spring '22, RS256-capable) is the Token 1 generator. Available in OMM-rendered surfaces — confirmed for SMS templates; **email-template availability requires verification against the specific journey template type** (Phase 5b prerequisite).                                                                                                                                                                              |
| `JBSystemFlow_Refill_Reminder_c`                                             | Already has both an SMS callsite (EventDefinitionKey `SalesforceObj62357...`) and an email callsite (EventDefinitionKey `SalesforceObj8e4d8a4f...`). Both fire `et4ae5__JBintFireBulkEvent` against the same data extension shape, with channel-specific gate predicates. See `patient-texting-refill-reminders.md` §4.1 for the per-channel gate detail. The magic-link work hooks into both callsites by editing each channel's template — no new flows. |
| B2B Commerce storefront (`Advanced RX Store` on `vforcesite`, Aura template) | The landing-page LWC + public route lives on this existing community. No new community needed.                                                                                                                                                                                                                                                                                                                                                             |
| `B2BUtils.getUserAccountID()` pattern                                        | Established account-scoping pattern for community-reachable controllers (see `B2BAddProductsToCart`, `B2BRefillFlowController`). The bridge controller's post-auth Apex calls follow this pattern.                                                                                                                                                                                                                                                         |
| `B2BAuthorizeDotNetUtils`                                                    | Canonical strict-NC outbound callout exemplar. Mirrors `Example/apex/IntegrationCallout.cls`. Use as the template for the JWT exchange callouts.                                                                                                                                                                                                                                                                                                           |
| Existing refill wizard                                                       | Built on `Account_Sig__c` transient state. The magic link's `retURL` lands here — _Phase 3 verification step confirms wizard initialization is clean from a magic-link-established session._                                                                                                                                                                                                                                                               |
| `TriggerDispatcher` framework + `CartItemTrigger` exemplar                   | If the project ends up needing a trigger (e.g., on `Magic_Link_Token__c` for jti collision detection), we follow this framework + a `Trigger_Activation_Setting__mdt` row.                                                                                                                                                                                                                                                                                 |
| `Auth.JWT`, `Auth.JWS`, `Auth.JWTBearerTokenExchange`                        | Standard Apex classes. Not version-gated. Available today on sourceApiVersion 66.0.                                                                                                                                                                                                                                                                                                                                                                        |

### §4.2 What we don't have — and how we work around it

| Missing                                                                                          | Workaround                                                                                                                                                                                                                                                                                                                                         |
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No Shield license.** No Platform Encryption / Event Monitoring / Field Audit Trail.            | (a) Audit retention via Custom Object writes archived to a Big Object for 6+ years. (b) Don't put PHI in JWT claims. (c) Standard at-rest encryption (Hyperforce + classic encryption on sensitive fields if any) is the floor. **Compliance counsel must sign off on this control set before build starts.**                                      |
| **No Platform Cache partition.** `Cache.Org` / `Cache.Session` with default partition will fail. | Use `Magic_Link_Token__c` (Custom Object) for jti store, indexed on `jti__c`. Atomic check-and-set via `upsert` with external ID. Slightly slower than Platform Cache but operationally fine at refill-reminder volume.                                                                                                                            |
| **No Person Accounts / no Multi-currency.**                                                      | Not relevant here, but flagged so nobody invents a path that depends on them.                                                                                                                                                                                                                                                                      |
| **B2B Commerce is Aura-template Lightning B2B Commerce on Core, not LWR.**                       | The public landing page goes on the Aura community. LWC works inside Aura — no template constraint. Don't generate code that assumes LWR / D2C / `--dxp-*` styling.                                                                                                                                                                                |
| **SMS character-budget pressure (160 chars minus brand prefix + opt-out).**                      | URL shortener via `Magic_Link_Short_Code__c` keeps the link short (~20 chars). Email doesn't share this pressure — a full-length JWT URL would fit — but **we keep one mint mechanism for both channels** (the short-code endpoint), so we have one auditable path and one set of tests. Email links resolve via the same `/r/:code` route as SMS. |

### §4.3 Org-state hazards specific to this project

**Critical sequencing dependency — SFMC credential rotation.** Three Apex classes have hardcoded SFMC OAuth `client_id` / `client_secret`:

- `InvokeWelcomeEmailPullerMC` (lines 9, 94, 141)
- `invokeMarketingCloudTriggeredSend` (lines 18, 62)
- `PrescribersToMarketingCloud` (line 15)

Per `MARISSA-HANDOFF.md` §2 #2, these credentials are treated as compromised; rotation is in flight. **This project does not start until that rotation completes.** Building cert-signed-token infrastructure that shares an SFMC integration surface with leaked credentials is bad sequencing — if the leak is exploited mid-build, the magic-link infrastructure is implicated.

**Not a blocker, but flag-worthy:**

- `B2BPaymentMethodsController` is `without sharing` + `@AuraEnabled` + payment-data DML. The new bridge controller is in the same architectural neighborhood. Do **not** copy that pattern wholesale. The bridge controller is `with sharing`; the _only_ `without sharing` code in this project is the documented short-code lookup helper (§7.6).
- 60% of existing Apex tests have zero `System.assert*` calls (`TEST-ASSERTION-AUDIT.md`). New tests for this project must carry assertions per `.claude/rules/apex.md`.
- Async error handling is at 3/10 conformance (CORRECTIONS row 19). Any async work in this project (e.g., the consumed-token purge batch and the auth-event archive batch) follows the `CustomerAddressJob` exemplar — try/catch around execute body + `Error_Log__c` insert.

### §4.4 Existing artifacts the lead dev should read first

In order:

1. `CLAUDE.md` — full org operating context.
2. `.claude/rules/apex.md` — all Apex conventions, especially "External callouts: Named Credentials only" and "Never accept caller-supplied SOQL for Database.query."
3. `business-context/storefront-data-audit.md` — the full B2B storefront data map. Read **first** for any storefront-touching code per CLAUDE.md §3.
4. `business-context/integrations/sfmc.md` — SFMC surface area + active hazards.
5. `business-context/workflows/patient-texting-refill-reminders.md` — the existing refill-reminder send pipeline this project hooks into. Pay close attention to §4.1 (channel gate predicates) and §3 (cancel-reason guards).
6. `business-context/objects/Refill.md` — refill-domain object the wizard operates on; gives context for the post-auth landing UX.
7. `business-context/objects/Account_Sig.md` — refill-wizard transient scratch state; informs the Phase 3 wizard-initialization verification.
8. `Example/apex/IntegrationCallout.cls` + `B2BAuthorizeDotNetUtils.cls` — strict-NC callout exemplars.

---

## §5 Prerequisites — must be true before build starts

1. **SFMC credential rotation complete.** All three classes use a Named Credential (e.g., `MCAuth`) instead of hardcoded literals. Verified by grep returning zero results for the leaked client_id in `force-app/main/default/classes/`. Status: in flight per `MARISSA-HANDOFF.md` §2 #2.
2. **Compliance counsel sign-off on the no-Shield audit/retention design.** Specifically: Big Object archival of auth events satisfies the 6-year retention obligation; no Field Audit Trail required.
3. **BAA confirmed to cover Experience Cloud + magic-link auth flow.** Likely already in scope (Experience Cloud is in the existing BAA), but verify in writing.
4. **Operational decision on link-compromise risk — both channels.** A magic link grants portal access to anyone with access to the patient's message. Channel-specific vectors:
   - **SMS:** SIM swap, shared family phone, lost device, screenshot forwarding, lock-screen previews, carrier message archive dumps.
   - **Email:** shared inbox access (spouse / caregiver), forwarding rules (autoforwarders to assistants or family), screenshots, mailing-list cross-posts, archived mailbox dumps, **email links sit in mailboxes for years** even after expiration.
     Risk acceptance must be documented per channel.
5. **Decision on URL-shortener strategy.** §6 covers this — the choice has security and compliance implications. Recommend an own-domain Salesforce Site (`refill.advancedrx.com`) over a third-party shortener; a third-party shortener creates a PHI-adjacent URL log on someone else's infrastructure.
6. **Email-channel deliverability review.** Confirm the magic-link domain (`refill.advancedrx.com`) doesn't trip the org's SPF/DKIM/DMARC posture and won't get classified as phishing by major mail providers (Gmail, Outlook, Apple Mail, Yahoo). The `https://refill.advancedrx.com/r/...` shape needs to come from a domain aligned with the SFMC sending domain or it'll get flagged. Likely requires a DKIM record + DMARC alignment review with whoever owns SFMC sending-domain config.
7. **Test SFMC sandbox available.** Marketing Cloud has its own sandbox model separate from Salesforce sandboxes. Confirm a non-prod SFMC instance is configured and reachable from `ClaudeTest`.

---

## §6 Implementation plan — phased

Each phase ends with a deployable, testable increment. Do not start phase N+1 until phase N is verified in `ClaudeTest`.

### Phase 0 — Pre-work

- Confirm §5 prerequisites.
- Generate signing certificate (RSA 4096-bit) externally via OpenSSL. Store private key in your secrets vault. Upload public key to the ECA. Upload private key to SFMC Key Management as `AdvancedRx-MagicLink-Key`.
- Lock down the cert rotation calendar. TLS cert lifespans drop to 200 days max in March 2026; plan annual cert rotation.
- **Phase 0 work item — prove the SFMC→SF authenticated callout pattern.** This is the first concrete blocker for §7.8. Deliverable: a working test callout from SFMC sandbox to `ClaudeTest` using SFMC Installed Package OAuth + a Named-Credential-clean Salesforce Site REST endpoint (a stub that returns `{"ok": true}` is sufficient to prove the auth chain). **Zero hardcoded credentials on either side.** Block Phase 5 on this.

### Phase 1 — Foundation

- External Client App created with the §7.1 settings.
- Custom Objects deployed: `Magic_Link_Token__c`, `Magic_Link_Short_Code__c`, `Magic_Link_Auth_Event__c` (all OWD `Private`). Big Object: `Magic_Link_Auth_Event_Archive__b`.
- Custom Metadata Type `Magic_Link_Allowed_Return_Path__mdt` deployed. Seed row: `/refill`.
- Permission set `Magic_Link_Service` created and assigned to the Site Guest user. Grants Apex class access only — **no direct object access**, because reads happen via documented `without sharing` helper (§7.6).

### Phase 2 — Token generation + validation core

- Apex classes: `MagicLinkTokenService` (Token 1 verify only — minting is in SFMC AMPScript; admin-issued links handled separately if needed), `MagicLinkValidator` (jti consumption), `MagicLinkBridgeController` (LWC entry point), `MagicLinkShortCodeReader` (`without sharing` helper for short-code lookup).
- Strict-NC callouts: NCs `SF_OAuth_Token` (target `/services/oauth2/token` on the community) and `SF_OAuth_SingleAccess` (target `/services/oauth2/singleaccess` on the community).
- Unit tests at ≥90% coverage with **assertion-per-method** per `.claude/rules/apex.md`. Mock the HTTP responses via `HttpCalloutMock`.

### Phase 3 — Landing page (LWC + Experience Cloud route)

- LWC `magicLinkLanding`. Reads `c__sc` URL param. Calls `MagicLinkBridgeController.bridge(shortCode)`. Sets `window.location` to the returned `frontdoor_uri`.
- Public page route on `Advanced RX Store` community at `/magic-link` (or `/r/:code` if the URL-shortener path lives directly on the community).
- Run `mcp__Salesforce_DX__run_lwc_accessibility_jest_tests` before deploy.
- **Verification step — refill-wizard landing UX.** Click magic link as a test patient (admin-issued for test). Confirm the refill wizard loads at the expected starting screen with **no stale `Account_Sig__c` values** affecting the experience (the `Account_Sig__c` object carries transient scratch state that "always treat pre-read temp-field values as suspect" per `business-context/objects/Account_Sig.md`). If wizard state initialization is needed for the magic-link entry path, scope as a Phase 3.5 task with explicit boundaries — do not silently expand this project to refactor the wizard.

### Phase 4 — Production hardening

- `Magic_Link_Auth_Event_Archive_Batch` (Schedulable + Batchable): archives `Magic_Link_Auth_Event__c` records older than 30 days into `Magic_Link_Auth_Event_Archive__b`, deletes the source rows. Follows `CustomerAddressJob` async-error pattern.
- **`Magic_Link_Token_Purge_Batch` (Schedulable + Batchable):** deletes `Magic_Link_Token__c` rows where `Expires_At__c < TODAY - 7` (7-day post-expiry buffer for support investigations). Same `CustomerAddressJob` async-error pattern. Without this, the jti store grows unbounded.
- Token revocation flow: admin-screen Apex action `MagicLinkAdminController.invalidateAllForUser(userId)`.
- Per-user issuance limit enforcement (max 3 active unconsumed magic links per user; new issue invalidates older unconsumed tokens). Per-IP rate limiting moves to Site / WAF / CDN — **not Apex** (see §8.3).

### Phase 5a — SMS template wiring + go-live

- AMPScript snippet (§7.7 SMS variant) added to the SMS template invoked by `JBSystemFlow_Refill_Reminder_c`'s SMS callsite.
- A/B rollout: 10% of refill-reminder SMS sends → 50% → 100% over 2 weeks, watching `Magic_Link_Auth_Event__c` for failure rate.

### Phase 5b — Email template wiring + go-live

- **Pre-work verification:** confirm `GetJWTByKeyName` and `HTTPPost2` are available in the OMM-rendered email-template surface used by the refill-reminder journey. Spring '22 release notes say yes for OMM emails generally; verify against the _specific template type_ this journey uses (Triggered Send Definition vs. Journey Builder Email Activity vs. Salesforce Email — they have slightly different AMPScript runtime contexts).
- AMPScript snippet (§7.7 email variant) added to the OMM email template invoked by `JBSystemFlow_Refill_Reminder_c`'s email callsite.
- Same A/B rollout pattern as 5a, on the email cohort.

---

## §7 Components to build — technical detail

### §7.1 External Client App configuration

| Setting                  | Value                                                                                                                                                                                                                                                      |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API name                 | `Magic_Link_Bridge_ECA`                                                                                                                                                                                                                                    |
| OAuth enabled            | Yes                                                                                                                                                                                                                                                        |
| Callback URL             | `https://login.salesforce.com/services/oauth2/success` (required field; not used in JWT Bearer flow)                                                                                                                                                       |
| Use digital signatures   | Yes; upload public cert from §6 Phase 0                                                                                                                                                                                                                    |
| OAuth scopes             | `api`, `web`, `refresh_token offline_access`, `openid` — **all four required.** `web` enables session bridging; `refresh_token` is required despite JWT Bearer not returning refresh tokens (omitting it produces "refresh_token scope is required" error) |
| Permitted users          | "Admin approved users are pre-authorized"                                                                                                                                                                                                                  |
| Profile / permission set | Assign the community member profile (`Advanced RX Customer Community Plus Login User` per `storefront-data-audit.md` line 136)                                                                                                                             |
| IP relaxation            | "Relax IP restrictions" — required because clicks come from arbitrary patient IPs                                                                                                                                                                          |
| Refresh token policy     | "Immediately expire refresh token" (we don't use refresh tokens)                                                                                                                                                                                           |

### §7.2 Custom Objects

#### `Magic_Link_Token__c` — jti store (OWD: **Private**)

| Field                    | Type                                   | Notes                                                                                                                  |
| ------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `Name`                   | Auto Number                            | `MLT-{0000000000}`                                                                                                     |
| `jti__c`                 | Text(64), unique, external ID, indexed | UUID v4 from Token 1                                                                                                   |
| `Mapping_Id__c`          | Text(64), unique, external ID, indexed | UUID — the value carried in Token 1 `sub`. Maps opaquely to `User__c`. Decouples `jti` rotation from the user mapping. |
| `User__c`                | Lookup(User), required                 | Patient who received the link. Resolved from Mapping_Id\_\_c.                                                          |
| `Issued_At__c`           | DateTime                               | Token 1 `iat` (epoch seconds at write, stored as DateTime)                                                             |
| `Expires_At__c`          | DateTime, indexed                      | Token 1 `exp`                                                                                                          |
| `Consumed_At__c`         | DateTime                               | Set atomically on first valid bridge attempt; if non-null, reject                                                      |
| `Consumed_From_IP__c`    | Text(45)                               | IPv4 or IPv6, captured from `Auth.SessionManagement.getCurrentSession()`                                               |
| `Consumed_User_Agent__c` | LongTextArea(1024)                     | UA string                                                                                                              |

OWD `Private`. Site Guest user has **no direct access**. The bridge controller reads/writes via `MagicLinkValidator` (which uses a `without sharing` inner method documented per §7.6).

#### `Magic_Link_Short_Code__c` — URL shortener (OWD: **Private**)

| Field                  | Type                                   | Notes                                                                                                                        |
| ---------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `Name`                 | Auto Number                            | `MLSC-{0000000000}`                                                                                                          |
| `Short_Code__c`        | Text(12), unique, external ID, indexed | Random base62 (~71 bits entropy at 12 chars)                                                                                 |
| `Token_1__c`           | LongTextArea(4096)                     | The full signed JWT (Token 1)                                                                                                |
| `Expires_At__c`        | DateTime, indexed                      | Same as Token 1 `exp` (10 min)                                                                                               |
| `Consumed__c`          | Checkbox                               | True after bridge resolves                                                                                                   |
| `Created_By_Source__c` | Picklist                               | `SFMC_SMS`, `SFMC_Email`, `Manual_Admin`, `Test`                                                                             |
| `Channel__c`           | Picklist                               | `SMS`, `Email` (mirrors the channel claim; supports analytics)                                                               |
| `Return_URL__c`        | Text(255)                              | Relative path; **validated against `Magic_Link_Allowed_Return_Path__mdt` at mint time and again at consume time** (see §7.5) |

OWD `Private`. Site Guest has no direct access; reads happen through `MagicLinkShortCodeReader` (`without sharing` helper).

#### `Magic_Link_Auth_Event__c` — auth audit log (operational, 30-day rolling, OWD: **Private**)

| Field                | Type               | Notes                                                                                                                                                                                                                                                          |
| -------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Name`               | Auto Number        | `MLAE-{0000000000}`                                                                                                                                                                                                                                            |
| `User__c`            | Lookup(User)       | Nullable — failed attempts may not resolve to a user                                                                                                                                                                                                           |
| `Event_Type__c`      | Picklist           | `Token_Issued`, `Bridge_Attempt`, `Bridge_Success`, `Bridge_Prefetch_Skipped`, `Bridge_Failure_Expired`, `Bridge_Failure_Consumed`, `Bridge_Failure_Invalid_Signature`, `Bridge_Failure_Invalid_RetUrl`, `Bridge_Failure_Callout`, `Bridge_Failure_Rate_Limit` |
| `jti__c`             | Text(64), indexed  | Cross-reference to `Magic_Link_Token__c`                                                                                                                                                                                                                       |
| `Source_IP__c`       | Text(45)           |                                                                                                                                                                                                                                                                |
| `User_Agent__c`      | LongTextArea(1024) |                                                                                                                                                                                                                                                                |
| `Event_Detail__c`    | LongTextArea(4096) | Failure reason as a category code or HTTP status. **Never contains token contents or stack traces** — those go to `Error_Log__c` per §8.5.                                                                                                                     |
| `Event_Timestamp__c` | DateTime, indexed  |                                                                                                                                                                                                                                                                |

#### `Magic_Link_Auth_Event_Archive__b` — Big Object for 6-year retention

Same shape as `Magic_Link_Auth_Event__c`. Index: `(User__c, Event_Timestamp__c DESC)`. Loaded by Phase 4 archive batch.

### §7.3 Apex classes

All classes:

- Explicit sharing keyword. Default `with sharing`. The `without sharing` helpers in `MagicLinkValidator` and `MagicLinkShortCodeReader` are the _documented exception case_ allowed by `.claude/rules/apex.md` (Site Guest user must read short-code records to resolve magic links; record-level access is enforced via single-row jti consume).
- `@AuraEnabled` methods throw `AuraHandledException`, never raw `Exception`.
- Tests carry per-method `System.assert*` on outcome.
- Strict-NC for callouts; no hardcoded URLs or credentials.

#### `MagicLinkTokenService` (`with sharing`)

```apex
// Verify Token 1 — signature (RS256 against public key in SF CKM), exp,
// claim shape. Returns parsed claims; throws MagicLinkValidationException
// on any failure. NOTE: minting Token 1 is done in SFMC AMPScript — Apex
// minting is a future scope for admin-issued links.
public static MagicLinkClaims verifyToken(String tokenJwt);
```

#### `MagicLinkValidator` (`with sharing` outer, `without sharing` inner helper documented)

```apex
// Atomic check-and-set on jti. Throws MagicLinkValidationException if already
// consumed. Uses upsert by external ID for atomicity.
public static void consumeJti(String jti, String fromIp, String userAgent);

// Used by Phase 4 admin invalidate-all flow.
public static void invalidateAllUnconsumedFor(Id userId);

// Documented without-sharing helper — Site Guest user runs this to read
// the jti store. Record-level access enforced via single-row consume.
private without sharing class JtiStore { ... }
```

#### `MagicLinkShortCodeReader` (`without sharing`, documented exception)

```apex
// Site Guest user must read Magic_Link_Short_Code__c records to resolve
// magic links. Record-level access is enforced because the short-code value
// itself is high-entropy and the row is consumed once via jti.
public without sharing class MagicLinkShortCodeReader {
  public static Magic_Link_Short_Code__c lookup(String shortCode);
}
```

#### `MagicLinkBridgeController` (`with sharing`) — `@AuraEnabled` entry point

```apex
@AuraEnabled
public static BridgeResult bridge(String shortCode) {
    try {
        // 1. Prefetch detection (heuristic on UA + Accept-Language).
        //    If prefetch → log Bridge_Prefetch_Skipped, return null (LWC
        //    will display "Tap to continue" button that retries on click).
        // 2. Resolve shortCode → Token 1 via MagicLinkShortCodeReader.lookup.
        // 3. Validate retUrl against Magic_Link_Allowed_Return_Path__mdt.
        // 4. MagicLinkTokenService.verifyToken(token1).
        // 5. MagicLinkValidator.consumeJti(...).
        // 6. Mint Token 2 via Auth.JWT + Auth.JWS.
        // 7. Exchange via callout:SF_OAuth_Token.
        // 8. POST access_token to callout:SF_OAuth_SingleAccess.
        // 9. Insert Magic_Link_Auth_Event__c (Bridge_Success).
        return new BridgeResult(frontdoorUri);
    } catch (MagicLinkValidationException e) {
        // Token-level failure: expired, consumed, signature invalid, retUrl rejected.
        // Log specific Bridge_Failure_* code to Magic_Link_Auth_Event__c.
        logAuthEvent(e.failureCode, /* no token contents */ e.publicMessage);
        throw new AuraHandledException('Sign-in link is no longer valid. Please request a new one.');
    } catch (CalloutException e) {
        // Infrastructure: SF token endpoint or singleaccess returned an error.
        // Log Bridge_Failure_Callout with HTTP status (no body, no token contents).
        logAuthEvent('Bridge_Failure_Callout', String.valueOf(e.getMessage()).left(100));
        throw new AuraHandledException('Unable to complete sign-in. Please try again or request a new link.');
    } catch (Exception e) {
        // Code bug or unexpected state. Stack trace goes to Error_Log__c, NOT Magic_Link_Auth_Event__c.
        insert new Error_Log__c(
            Error__c           = 'MagicLinkBridgeController.bridge unexpected: ' + e.getMessage(),
            Exception_Stack__c = e.getStackTraceString()
        );
        throw new AuraHandledException('Sign-in failed. Please try again or contact support.');
    }
}
```

#### `MagicLinkAuthEventArchiveBatch` (Schedulable + Batchable)

- Follows `CustomerAddressJob` exemplar (try/catch in execute → `Error_Log__c` insert).
- Iterates `Magic_Link_Auth_Event__c WHERE Event_Timestamp__c < TODAY - 30`.
- Inserts each row into `Magic_Link_Auth_Event_Archive__b` via `Database.insertImmediate`.
- Deletes source rows in same batch chunk.
- Schedule daily at 02:00 site time.

#### `MagicLinkTokenPurgeBatch` (Schedulable + Batchable)

- Same async-error pattern.
- Iterates `Magic_Link_Token__c WHERE Expires_At__c < TODAY - 7`.
- Hard-deletes (no archive — tokens contain no data of audit value beyond what `Magic_Link_Auth_Event__c` already captures).
- Schedule daily at 03:00 site time.

### §7.4 Named Credentials (strict-NC, modern style)

| NC name                 | URL                                                       | Auth                    |
| ----------------------- | --------------------------------------------------------- | ----------------------- |
| `SF_OAuth_Token`        | `https://{community-domain}/services/oauth2/token`        | None (token is in body) |
| `SF_OAuth_SingleAccess` | `https://{community-domain}/services/oauth2/singleaccess` | None (token is in body) |

Both NCs use Hyperforce-style `NamedCredentialParameter` for the URL. No External Credential needed (no auth header — body-only auth via the JWT assertion or access token).

**Do not paste the community URL into Apex. Reference `callout:SF_OAuth_Token` and `callout:SF_OAuth_SingleAccess` everywhere.**

### §7.5 retURL allowlist — `Magic_Link_Allowed_Return_Path__mdt`

| Field            | Type             | Notes                                                                                                                                                                        |
| ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DeveloperName`  | (CMDT standard)  | Friendly identifier, e.g., `Refill_Wizard`                                                                                                                                   |
| `Path_Prefix__c` | Text(80), unique | The relative path prefix permitted, e.g., `/refill`. Match is prefix-anchored: `/refill/something` matches; `/refillX` does not (compare with trailing-slash normalization). |

Validation lives in **two places** (defense in depth):

- **At mint time** in `MagicLinkShortCodeRestResource` (§7.8) — reject the request if `retUrl` doesn't match a CMDT allowlist row.
- **At consume time** in `MagicLinkBridgeController.bridge()` — re-check the retURL against the allowlist before returning the frontdoor URI. Catches the case where CMDT rows changed between mint and consume (e.g., a path was removed for security and pre-minted links shouldn't honor it).

Seed for v1: one row with `Path_Prefix__c = /refill`. Adding paths is a deploy + review, not a runtime config change.

### §7.6 LWC and Experience Cloud route

- LWC `magicLinkLanding`. Reads `c__sc` URL param. Calls `MagicLinkBridgeController.bridge(shortCode)`. Sets `window.location` to the returned `frontdoor_uri`. If `bridge()` returns null (prefetch detected), renders a "Tap to continue" button that re-invokes on click.
- Public page `/magic-link` (or `/r/:code` if the short-code path is exposed directly) on the `Advanced RX Store` community.
- Aura template page (matches existing community template). Drop `magicLinkLanding` LWC on the page.
- Site Guest user permission set `Magic_Link_Service` grants:
  - **Apex class access only:** `MagicLinkBridgeController`. Nothing else.
  - **No direct object permissions** on `Magic_Link_Token__c`, `Magic_Link_Short_Code__c`, or `Magic_Link_Auth_Event__c`. The bridge controller's `without sharing` helpers handle the reads.

LWC implementation (sketch):

```javascript
import { LightningElement } from "lwc";
import bridge from "@salesforce/apex/MagicLinkBridgeController.bridge";

export default class MagicLinkLanding extends LightningElement {
  error;
  showRetryButton = false;
  code;

  connectedCallback() {
    const params = new URLSearchParams(window.location.search);
    this.code = params.get("c__sc");
    if (!this.code) {
      this.error = "Invalid link.";
      return;
    }
    this.invokeBridge();
  }

  invokeBridge() {
    bridge({ shortCode: this.code })
      .then((result) => {
        if (!result) {
          this.showRetryButton = true;
          return;
        } // prefetch path
        window.location.assign(result.frontdoorUri);
      })
      .catch((e) => {
        this.error = e.body?.message || "Sign-in failed.";
      });
  }

  handleRetryClick() {
    this.showRetryButton = false;
    this.invokeBridge();
  }
}
```

- Use base Lightning components for the spinner + error states. No `innerHTML`. Use `this.template.querySelector` if needed.
- Run `mcp__Salesforce_DX__run_lwc_accessibility_jest_tests` before deploy.

### §7.7 SFMC AMPScript snippets

Both channels use the same pattern: mint Token 1 with `GetJWTByKeyName`, POST to the short-code endpoint, render the link.

**Common gotcha — JWT timestamp format:** JWT spec requires `iat` and `exp` as **integer seconds since epoch (UTC)**, not ISO-8601 strings. `Auth.JWT.verify()` will reject ISO strings. Use AMPScript's `DateDiff` against the Unix epoch to produce epoch seconds. Verify the SFMC account default timezone; if non-UTC, account for the offset (or pass through to the short-code endpoint's tolerance window — Apex verify allows ±60 seconds slop on `exp` to absorb minor clock drift).

#### SMS template (MobileConnect)

```ampscript
%%[
    /* ---- Token 1 mint ---- */
    SET @jti        = GUID()
    SET @sub        = AttributeValue("Magic_Link_Mapping_Id")    /* opaque UUID, NOT user ID */

    /* Epoch seconds — JWT spec is integers, not ISO strings.            */
    /* DateDiff("S") returns difference in seconds.                       */
    /* Verify SFMC account TZ matches expectation; Apex verify tolerates  */
    /* ±60s drift.                                                        */
    SET @nowEpoch   = DateDiff("1970-01-01 00:00:00", Now(), "S")
    SET @expEpoch   = Add(@nowEpoch, 600)                        /* 10 min */

    /* iat and exp are JSON integers (no quotes around them) */
    SET @payload    = Concat('{"sub":"', @sub, '","jti":"', @jti, '","iat":', @nowEpoch, ',"exp":', @expEpoch, '}')
    SET @token1     = GetJWTByKeyName("AdvancedRx-MagicLink-Key", "RS256", @payload)

    /* ---- POST to /svc/short-code (SFMC Installed Package OAuth) ---- */
    SET @reqBody    = Concat('{"token":"', @token1, '","retUrl":"/refill","channel":"SMS"}')
    SET @resp       = HTTPPost2("https://refill.advancedrx.com/svc/short-code", "application/json", @reqBody, true, @respStatus)
    SET @shortCode  = JSONParseValue(@resp, "shortCode")
]%%

Tap to refill: https://refill.advancedrx.com/r/%%=@shortCode=%%
Reply STOP to opt out.
```

#### Email template (OMM)

Same minting block; the surrounding markup changes from SMS-string to HTML, and the link is rendered as a button.

```ampscript
%%[
    /* ---- Token 1 mint (identical to SMS) ---- */
    SET @jti        = GUID()
    SET @sub        = AttributeValue("Magic_Link_Mapping_Id")
    SET @nowEpoch   = DateDiff("1970-01-01 00:00:00", Now(), "S")
    SET @expEpoch   = Add(@nowEpoch, 600)
    SET @payload    = Concat('{"sub":"', @sub, '","jti":"', @jti, '","iat":', @nowEpoch, ',"exp":', @expEpoch, '}')
    SET @token1     = GetJWTByKeyName("AdvancedRx-MagicLink-Key", "RS256", @payload)

    /* channel value differs — supports analytics + downstream gate logic */
    SET @reqBody    = Concat('{"token":"', @token1, '","retUrl":"/refill","channel":"Email"}')
    SET @resp       = HTTPPost2("https://refill.advancedrx.com/svc/short-code", "application/json", @reqBody, true, @respStatus)
    SET @shortCode  = JSONParseValue(@resp, "shortCode")
]%%

<a href="https://refill.advancedrx.com/r/%%=@shortCode=%%"
   style="display:inline-block; padding:12px 24px; background:#0070d2; color:#fff; text-decoration:none; border-radius:4px;">
    View Your Prescription
</a>
```

**Phase 5b verification:** confirm `GetJWTByKeyName` and `HTTPPost2` execute correctly in the email-template surface used by the refill-reminder journey. Spring '22 release notes say yes for OMM emails generally; verify against the _specific template type_ this journey uses (Triggered Send Definition vs. Journey Builder Email Activity vs. Salesforce Email — they have slightly different AMPScript runtime contexts).

### §7.8 Short-code REST endpoint — `MagicLinkShortCodeRestResource`

This is the SFMC→SF authenticated callout target. **It sits in the same architectural neighborhood as `getOrderListNew` and `B2BCustomMetadataController`** (community-reachable Apex with patient-data implications). Treat with the matching paranoia.

```apex
@RestResource(urlMapping='/svc/short-code/*')
global with sharing class MagicLinkShortCodeRestResource {
  @HttpPost
  global static ShortCodeResponse createShortCode() {
    try {
      // 1. Auth: SFMC Installed Package OAuth bearer is verified by Site
      //    REST framework before this method runs (Phase 0 deliverable).
      //    Defense in depth: re-verify caller is the expected integration
      //    user via UserInfo.getUserId() against a CMDT allowlist.
      // 2. Parse + strict-schema validate body:
      //      { token: String, retUrl: String, channel: String IN ('SMS','Email') }
      //    Reject if any field is missing, oversized, or wrong type.
      //    Reject if body length > 4500 (token max + slack).
      // 3. Validate retUrl against Magic_Link_Allowed_Return_Path__mdt.
      // 4. Generate random base62 short code (12 chars, ~71 bits entropy).
      //    Retry on Short_Code__c uniqueness collision (max 3 retries).
      // 5. Decode Token 1 (no signature verify — that happens at consume
      //    time on the bridge path) just to extract jti + exp for the
      //    Magic_Link_Short_Code__c row's Expires_At__c.
      // 6. Insert Magic_Link_Short_Code__c.
      // 7. Insert Magic_Link_Auth_Event__c (Token_Issued).
      // 8. Return { shortCode: <code> }.
    } catch (Exception e) {
      insert new Error_Log__c(
        Error__c = 'MagicLinkShortCodeRestResource failure: ' + e.getMessage(),
        Exception_Stack__c = e.getStackTraceString()
      );
      // Return generic error; do not echo internal state.
      RestContext.response.statusCode = 500;
      return new ShortCodeResponse('Unable to create link');
    }
  }

  global class ShortCodeResponse {
    public String shortCode;
    public String error;
    // ...
  }
}
```

**Endpoint requirements:**

- Authenticated via SFMC Installed Package OAuth (Phase 0 deliverable). **Zero hardcoded credentials in the endpoint or in the AMPScript snippet that calls it.**
- Strict request-body schema. Reject anything that isn't `{token, retUrl, channel}` with the expected types and lengths.
- `channel` enum: `SMS`, `Email`. Reject anything else.
- `retUrl` validated against `Magic_Link_Allowed_Return_Path__mdt`.
- No SOQL accepting caller-supplied strings. No `Database.query` with concatenation. (Same anti-pattern as `B2BCustomMetadataController` — do **not** replicate it here.)
- Rate limiting at the **Site level or upstream WAF/CDN**, not in Apex — see §8.3.
- Async error path: try/catch → `Error_Log__c` insert per `.claude/rules/apex.md`.
- Test class with assertions per method covering: happy path SMS, happy path email, rejected channel, rejected retUrl, malformed body, oversized body, retry-on-collision happy path, retry exhaustion, callout authentication failure.

---

## §8 Security and HIPAA controls

### §8.1 Token security

- Token 1 RS256-signed with a 4096-bit RSA keypair. Private key in SFMC Key Management (for AMPScript) and the public key in Salesforce Certificate and Key Management (for Apex verify).
- Token 1 lifetime: 10 minutes.
- Token 1 contents: `sub` (**opaque mapping UUID, NOT the Salesforce user ID**), `jti` (UUID v4), `iat` (epoch seconds), `exp` (epoch seconds). **No name, DOB, prescription detail, address, user ID, or any PHI.**
- The mapping UUID → User ID resolution lives only in `Magic_Link_Token__c.Mapping_Id__c` → `User__c`. A leaked Token 1 doesn't directly expose the user identity.
- Token 2 (Salesforce JWT Bearer assertion) lifetime: 180 seconds. Generated at click time.
- One-time-use enforcement: `jti` upsert with external-ID-based uniqueness; second consume attempt raises a DML duplicate error caught and logged.

### §8.2 Audit retention without Shield

- Live operational log in `Magic_Link_Auth_Event__c` (30-day rolling window).
- Archived to `Magic_Link_Auth_Event_Archive__b` Big Object, retained 7 years (HIPAA 6-year minimum + 1-year safety margin).
- Big Object queryable via SOQL with the `(User__c, Event_Timestamp__c DESC)` index — sufficient for breach-investigation lookups.

### §8.3 Rate limiting and replay defense

The actual replay defense is **per-jti one-time-use**, not per-IP rate limiting. Per-IP rate limiting in Apex via SOQL count would have a TOCTOU window and would not work behind carrier NAT (most SMS clicks come from mobile carriers that NAT thousands of users behind a few IPs).

- **Per-jti enforcement** (one-time-use via `Magic_Link_Token__c.Consumed_At__c` atomic check-and-set): the load-bearing replay defense. Keep.
- **Per-user issuance limit:** max 3 active unconsumed magic links per user. New issue invalidates older unconsumed tokens. Implemented in `MagicLinkShortCodeRestResource` (§7.8) at mint time. Defends against token-mint-spam.
- **Per-IP rate limiting:** moved out of Apex hot path. Implement at the Site / WAF / CDN layer. **Do not put SOQL-count rate-limit checks in `MagicLinkBridgeController.bridge()`** — it doesn't work behind NAT and adds latency without security value.

### §8.4 Replay and link-leakage defense

- Short token lifetime + one-time use + IP capture on consume gives strong replay resistance for the SMS channel.
- Email links sit in mailboxes for years. The 10-minute Token 1 expiry means an attacker who finds a year-old email containing the link can't use it. The risk window is **between send and click**.
- Link compromise (SMS interception, screenshot forwarding, family-shared phone, email forwarding rules, archived mailbox dumps) is the residual risk. Mitigation: short TTL + one-time use + admin tooling to invalidate-all-for-user. **Acceptance documented per channel in §5 prereq #4.**

### §8.5 Logging hygiene

- No JWT contents (Token 1 or Token 2) ever written to `System.debug`, `Error_Log__c`, `Magic_Link_Auth_Event__c`, or `Magic_Link_Auth_Event_Archive__b`.
- Bridge failure reason is logged as a category code (`Bridge_Failure_Expired`, `Bridge_Failure_Callout`, etc.), not as parsed claims or HTTP response bodies.
- LWC error UI shows a generic "Sign-in failed" / "Link no longer valid" message — never the underlying exception or token state.
- Code bugs and unexpected exceptions go to `Error_Log__c` (with stack trace) — **not** to `Magic_Link_Auth_Event__c` (which is the patient-facing audit and should stay clean of internal state).
- **AMPScript snippets in send templates do not log token contents to SFMC tracking extensions or any data extension.** Token values stay in HTTP request bodies only. SFMC's send-log retention is long; we do not want signed JWTs sitting in it.

### §8.6 Session security

- Session timeout for community users — confirm current setting is appropriate for healthcare (30 min idle is a reasonable target; current org setting unverified).
- "Lock sessions to the domain in which they were first used" — verify enabled.
- HTTPS-only cookies — verify enabled.

---

## §9 Testing and validation

### §9.1 Apex unit tests

- `MagicLinkTokenServiceTest` — verify happy path, expired token, tampered signature, malformed JSON, missing claims, ISO-string timestamps (negative — confirms epoch-only).
- `MagicLinkValidatorTest` — first-consume succeeds; second-consume raises; concurrent-consume race (use `Test.startTest` + parallel-Future-fan-out simulation).
- `MagicLinkBridgeControllerTest` — full bridge with `HttpCalloutMock` for both `/services/oauth2/token` and `/services/oauth2/singleaccess`. Assert `frontdoor_uri` returned to caller. Assert `Magic_Link_Auth_Event__c` row inserted with correct `Event_Type__c`. Negative-path tests for each `Bridge_Failure_*` code, plus `Bridge_Prefetch_Skipped`. Confirm `Error_Log__c` (not `Magic_Link_Auth_Event__c`) receives unexpected-exception writes.
- `MagicLinkShortCodeRestResourceTest` — happy path SMS, happy path email, rejected channel, rejected retUrl, malformed body, oversized body, collision retry, collision exhaustion.
- `MagicLinkAuthEventArchiveBatchTest` — assert old rows moved to Big Object; assert source rows deleted; assert error path inserts `Error_Log__c`.
- `MagicLinkTokenPurgeBatchTest` — assert expired-7+-days rows deleted; assert recent rows preserved; assert error path inserts `Error_Log__c`.

**Every test method carries at least one outcome-asserting `System.assert*`.** No `increaseCoverage()` no-ops.

### §9.2 LWC Jest tests

- `magicLinkLanding.test.js` — happy path (mock `bridge()` resolves with `frontdoor_uri`, assert `window.location` set), error path (mock `bridge()` rejects, assert error text rendered), prefetch path (mock `bridge()` resolves null, assert retry button rendered, simulate click, assert second `bridge()` call).
- Run `mcp__Salesforce_DX__run_lwc_accessibility_jest_tests`.

### §9.3 Integration tests in `ClaudeTest`

End-to-end with a test SFMC sandbox:

**SMS channel:**

- Issue Token 1 manually → land on community page → assert authenticated session.
- Click-after-expiry test: issue Token 1, wait 11 minutes, click, assert friendly error.
- Replay test: issue Token 1, click in browser A, click same link in browser B, assert browser B sees error.
- Cross-domain test: confirm community Aura shell loads correctly post-frontdoor.
- Mobile test: iOS Safari, iOS Chrome, Android Chrome, Android Samsung Internet.

**Email channel:**

- Render a test email with the magic link. Click the link from each of:
  - Gmail web (Chrome desktop)
  - Gmail iOS app
  - Gmail Android app
  - Outlook web
  - Outlook desktop
  - Apple Mail iOS
  - Apple Mail macOS
- **Email-link prefetch test:** trigger Microsoft Safe Links / Gmail link checking by sending a test email through a corporate-monitored mailbox. Confirm the prefetch hits `/r/{code}` with a generic UA and is detected by the §7.3 prefetch heuristic. Confirm the patient's actual click (with a real browser UA) succeeds afterward.
- Forwarded-email test: forward a magic-link email to a second address. Click from the second address. Confirm jti consume happens once (whichever browser clicks first) and the second click sees a friendly error.

### §9.4 Penetration test items

- jti collision attack — attempt to forge a Token 1 with a `jti` matching an existing consumed record.
- IDOR attack — attempt to substitute another mapping UUID in the `sub` claim and observe whether bridging succeeds for the wrong user.
- URL tampering — modify the short code, the embedded token, the retURL.
- Open redirect via retURL — verify only allowlisted relative paths are accepted, both at mint and at consume.
- Timing-attack on signature verification (constant-time comparison).
- Header injection on the callout to `/services/oauth2/token`.
- Malformed JWT header (algorithm confusion: try `alg=none`, try `alg=HS256` with the public key as the secret).

---

## §10 Risks and open questions

| #   | Risk / question                                                                                                                                                                                                                                                                                    | Owner           | Resolution path                                                                                                                                                                                                                                                                                                                                                 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Compliance counsel sign-off on no-Shield audit/retention design                                                                                                                                                                                                                                    | Kyle + counsel  | §5 prereq #2 — needs a written memo before Phase 1                                                                                                                                                                                                                                                                                                              |
| 2   | SFMC credential rotation completion date                                                                                                                                                                                                                                                           | Marissa         | Tracking in `MARISSA-HANDOFF.md` §2 #2; this project blocks until done                                                                                                                                                                                                                                                                                          |
| 3   | URL shortener strategy: own Salesforce Site domain vs. third-party                                                                                                                                                                                                                                 | Lead dev + Kyle | §6 Phase 0 — recommend own-domain via Salesforce Site to avoid third-party PHI exposure                                                                                                                                                                                                                                                                         |
| 4   | Acceptance of link-compromise risk per channel (SMS: SIM swap / family device; Email: shared inbox / forwarding rules / archived mailbox dumps / years-in-mailbox lifecycle)                                                                                                                       | Kyle + counsel  | §5 prereq #4 — needs documented risk acceptance per channel                                                                                                                                                                                                                                                                                                     |
| 5   | retURL allowlist — covered by `Magic_Link_Allowed_Return_Path__mdt` (§7.5) with mint-time + consume-time validation                                                                                                                                                                                | Lead dev        | **RESOLVED in §7.5** — CMDT-driven allowlist, validated at mint and consume. Adding new paths is a deploy + review.                                                                                                                                                                                                                                             |
| 6   | Spring '26 cert lifespan changes (200-day max from March 2026, 47-day max by 2029)                                                                                                                                                                                                                 | Lead dev        | Cert rotation runbook + calendar reminders set up in Phase 0                                                                                                                                                                                                                                                                                                    |
| 7   | Test SFMC sandbox availability                                                                                                                                                                                                                                                                     | Marissa?        | §5 prereq #7 — confirm or scope sandbox setup into Phase 0                                                                                                                                                                                                                                                                                                      |
| 8   | Patient population using outdated mobile browsers                                                                                                                                                                                                                                                  | Kyle            | Acceptance test in §9.3 covers iOS/Android current; document graceful-failure UX for unsupported browsers                                                                                                                                                                                                                                                       |
| 9   | Session-lock-to-domain setting — verify enabled                                                                                                                                                                                                                                                    | Lead dev        | §8.6 — pre-Phase 1 verification                                                                                                                                                                                                                                                                                                                                 |
| 10  | Refill wizard landing UX from magic-link session not yet verified. Wizard's `Account_Sig__c` transient state could carry stale values from prior invocations and surface a wizard-initialization scope addition during Phase 3.                                                                    | Lead dev        | Phase 3 verification step — if wizard state init is needed, scope as Phase 3.5 with explicit boundaries                                                                                                                                                                                                                                                         |
| 11  | Marketing Cloud Connect (`et4ae5`) managed-package upgrade risk during build window. If `et4ae5` ships an update that changes `JBintFireBulkEvent` payload or breaks `GetJWTByKeyName`, Phase 5 stalls.                                                                                            | Lead dev        | Pin SFMC sandbox to current `et4ae5` version through go-live; track release notes; stage upgrades in sandbox first                                                                                                                                                                                                                                              |
| 12  | Email-link prefetch by mail-client safety scanners (Microsoft Safe Links, Gmail link checking, corporate email gateways) consuming the jti before the patient clicks, causing the actual click to fail with "already used"                                                                         | Lead dev        | Prefetch-detection heuristic in `MagicLinkBridgeController.bridge()` — UA + Accept-Language signature; if detected, log `Bridge_Prefetch_Skipped` and **do not** consume the jti. Document the heuristic in code. Document the failure-mode UX (if heuristic is wrong, patient sees "link already used" and has to request a new one — acceptable v1 behavior). |
| 13  | Contact ↔ SFMC subscriber mapping at token mint time. The AMPScript snippet references `AttributeValue("Magic_Link_Mapping_Id")` — this attribute must be present on the data extension that drives the refill-reminder journey. If absent, the AMPScript will fail silently or pass empty values. | Marissa or Kyle | Confirm the data-extension contract before Phase 5; if absent, scope an SFMC data-extension change (populate `Magic_Link_Mapping_Id` per Subscriber from the underlying Contact's UUID field) as part of Phase 5. May require a new field on Contact — `Magic_Link_Mapping_Id__c` (Text 64, unique, external ID, default-set on Contact insert via a flow).     |
| 14  | Fallback if patient's browser blocks JS (LWC won't render)                                                                                                                                                                                                                                         | Lead dev        | Server-rendered fallback via Salesforce Site with a "Continue" button — phase 2 if needed                                                                                                                                                                                                                                                                       |

---

## §11 Pre-deploy checklist (per CLAUDE.md §8)

Every deploy of this project's components passes:

1. `mcp__Salesforce_DX__run_code_analyzer` returns clean.
2. `sf project deploy validate` against `ClaudeTest`.
3. `sf apex run test --test-level RunLocalTests` — every new test has outcome assertions.
4. `mcp__Salesforce_DX__run_lwc_accessibility_jest_tests` passes for `magicLinkLanding`.
5. Sharing keyword present on every new Apex class. The two `without sharing` helpers (`MagicLinkValidator.JtiStore`, `MagicLinkShortCodeReader`) carry one-line justification comments.
6. Zero hardcoded credentials in any new source.
7. Zero hardcoded user / profile / group / record-type IDs in new flows or VRs.
8. Target org confirmed as `ClaudeTest` via `mcp__Salesforce_DX__get_username`.
9. **Project-specific:** zero references to `frontdoor.jsp?sid=` GET pattern. All session bridging via `singleaccess`.
10. **Project-specific:** Site Guest user permissions reviewed — Apex class access only, no direct object access.
11. **Project-specific:** `Magic_Link_Allowed_Return_Path__mdt` seeded with the current allowed paths. Any new path additions go through code review (CMDT change is in source control under `force-app/main/default/customMetadata/`).
12. **Project-specific:** SFMC→SF short-code endpoint authenticated via SFMC Installed Package OAuth. Zero hardcoded credentials in the AMPScript snippet or the Apex endpoint.

---

## §12 References

### Internal

- `CLAUDE.md` — org operating rules.
- `.claude/rules/apex.md` — Apex conventions.
- `.claude/rules/flow.md` — Flow conventions.
- `business-context/integrations/sfmc.md` — SFMC surface area + the credential-leak hazard.
- `business-context/storefront-data-audit.md` — B2B storefront data map.
- `business-context/workflows/patient-texting-refill-reminders.md` — refill-reminder send pipeline (especially §4.1 channel gates).
- `business-context/objects/Refill.md` — refill domain object.
- `business-context/objects/Account_Sig.md` — refill-wizard transient scratch state.
- `Example/apex/IntegrationCallout.cls` — strict-NC outbound callout template.
- `MARISSA-HANDOFF.md` §2 #2 — SFMC credential rotation status.

### External (Salesforce official)

- OAuth 2.0 JWT Bearer Flow for Server-to-Server Integration (Salesforce Help).
- `Auth.JWT`, `Auth.JWS`, `Auth.JWTBearerTokenExchange` Apex Reference.
- `singleaccess` endpoint (Summer '24 release notes, Release 250).
- AMPScript `GetJWTByKeyName` (Marketing Cloud release notes, Spring '22).
- AMPScript `HTTPPost2`, `JSONParseValue`, `DateDiff` references.
- External Client Apps overview (Spring '26 release notes).

### External (community / reference implementations)

- `salesforceidentity/password-less-login` (GitHub) — Salesforce Identity team's reference.
- Nubessom Consulting magic-link blueprint (Nazim Aliyev, November 2023).
- `lekkimworld/salesforce-jwt-generator` (GitHub) — JWT token generator with frontdoor.jsp examples.

---

**End of document.** Lead dev: read §4 + §5 + §6 first; everything else fills in once those are clear.
