# ADR-048: External Client Portal Uses Email Magic-Link Auth — Not Hub PIN

## Status

Accepted, 2026-06-22.

## Status History

```yaml
status_history:
  - date: 2026-06-22
    status: Proposed
    changed_by: hkl
    reason: Formalising auth boundary between internal employees and external client users
    changed_via: adr-kit (360lm)
  - date: 2026-06-22
    status: Accepted
    changed_by: hkl
    reason: Magic-link auth live for recce-client portal; session namespace distinct from lm360-session
    changed_via: adr-kit (360lm)
```

## Context

The Recce client portal (`/recce-client/`) is used by external brand managers and client-side marketing staff (SM/ASM level) who are NOT 360LM employees. They need to log in and view Recce submissions, approve or comment on store visits, and track their brand's coverage. The internal employee auth system (Hub PIN — ADR-011) is not appropriate: external clients do not have employee PINs, are not in `expense.employees`, and must not share the internal identity layer. A separate auth mechanism with a separate session namespace is required.

## Decision

External client portal authentication uses **email magic-link / invite-token flow**:
1. Admin sends an invite to the client's email via the admin portal (writes to `client_invite` table + queues email).
2. Client clicks the link → link contains a short-lived signed token.
3. Token is validated server-side → `client_session` record created → session key written to `localStorage['recce-client-session']`.

`recce-client-session` is a **distinct session namespace** from `lm360-session` (employee), `lm360-activity-session`, and `lm360-client-session` — no risk of internal/external session crossover.

The client portal is its own PWA (`/recce-client/`) with its own auth tables (`client_user`, `client_session`, `client_invite`) — none of which are accessible via direct web_anon grants (see ADR-052).

**Decision Maker:** hkl

## Alternatives Considered

- **Hub PIN system for external clients.** Rejected: external clients are not employees; PINs require admin to assign and communicate a 4-digit code; PIN system is tied to employee identity in `expense.employees`; mixing external client identity with employee identity creates a security boundary violation.
- **OAuth (Google / Microsoft sign-in).** Rejected: requires GCP OAuth app setup per client domain; clients may not have Google/Microsoft accounts on their company email; OAuth complexity not justified when magic-link achieves the same result with one Brevo email call.
- **Username + password.** Rejected: passwords require hashing, forgot-password flow, and session management infrastructure; magic-link achieves the same single-factor security with less implementation overhead and no password to forget or mistype.
- **Shared the same session key as employee sessions.** Rejected: session key collision risk; different TTLs (client sessions may be longer-lived); different payloads; same-key sharing makes it impossible to tell in code whether a session is an employee or a client.

## Consequences

**Positive:**
- External clients can log in without needing an employee account or PIN.
- Magic-link is frictionless (no password to remember).
- Distinct session namespace prevents any confusion between employee and client sessions in code.
- Client auth tables are isolated from employee identity — no cross-contamination.

**Negative / Trade-offs:**
- Invite must be re-sent if the magic-link expires (short TTL) and client did not complete login.
- Requires email delivery to work — if client's spam filter blocks the invite, they cannot log in.
- Admin must send invites manually per client user — no self-registration.

**Risks and mitigations:**
- Magic-link token replay (link forwarded to another person): mitigated by single-use token (invalidated on first use) + short TTL (15 min).
- Session fixation: client_session is created server-side at token validation, not client-supplied.

## Related Decisions

- ADR-011 (PIN-based auth for employees) — the internal auth system; this ADR is the external counterpart.
- ADR-026 (cross-PWA session via localStorage) — recce-client-session follows the same key pattern.
- ADR-052 (client auth tables RPC-only) — client_user, client_session, client_invite have no direct web_anon grants.

## References

- `memory/dbt_client.md` — recce-client portal auth design, invite flow
- `memory/dbt_recce.md` — "own PWA /recce-client/ due to different auth (OAuth + email magic-link vs hub PIN)"
- `client/index.html` — magic-link validation, session creation
