Authentication
Guests sign in to an Edge Portal with a magic link — a one-time sign-in link addressed to the email on their booking. There are no portal passwords to create, forget, or reset. A verified link is exchanged for a short-lived portal session token, which authenticates every data call the portal UI makes.
The magic-link flow
Guest enters email → POST /auth/magic-link → 202 accepted (always)
Guest opens link → POST /auth/magic-link/verify → 201 { token, session }
Portal UI → data calls with the session token
Issuance. The portal UI submits only tenantId, portalId, and the guest's email. The traveler identity behind the link — subject, role, display name — is resolved server-side by the portal's data provider; an unauthenticated caller can never choose who a link signs in as. Identity resolution happens only for portals of type guest: an agent or operations portal never resolves a traveler, even for the same email in the same tenant.
No account enumeration. A known email and an unknown email get the same 202 { "status": "accepted" } response. Whether a matching traveler exists is never observable from the issuance endpoint.
The link token is a 256-bit opaque value with a short lifetime — 15 minutes by default, configurable per deployment up to a hard cap of one hour. Only its SHA-256 hash is ever stored or logged; the raw token exists only in the delivered link.
Verification is strictly single-use. The first POST /auth/magic-link/verify with a valid token consumes it atomically and returns a session token; any replay fails with a stable error code:
| Code | Meaning |
|---|---|
INVALID_TOKEN | The token was never issued (or has been swept after expiry) |
EXPIRED | Issued, but past its lifetime |
ALREADY_USED | Issued and already consumed once |
Consumed links are retained until their natural expiry so a replayed token reliably reports ALREADY_USED rather than decaying into INVALID_TOKEN.
Token storage
Single-use link state lives behind a storage seam with two implementations:
- In-memory (default without a database) — single-use holds per process instance only. Fine for local development and tests.
- Postgres (
magic_link_tokenstable) — selected automatically when a database is configured, and the precondition for running live portals. Consumption is an atomic conditional update, so multiple portal replicas racing the same token yield exactly one winner, across restarts.
When Postgres is selected but unreachable, issuance and verification fail loudly (5xx) rather than silently falling back to memory, which would defeat the single-use guarantee across replicas.
Email delivery of the link is handled upstream of the portal service. In development the issuance response echoes the raw token so local integration flows complete without email delivery; outside development the response only acknowledges the request.
Portal session tokens
A verified link mints a signed, tenant-scoped session token:
| Property | Value |
|---|---|
| Format | JWT signed with the tenant's secret (the platform-wide tenant JWT model) |
| Audience | edge-portal — tokens minted for other Edge products do not validate here |
| Claims | tenantId, portalId, sub (traveler subject), role (default traveler), and optional email / firstName / lastName |
| Lifetime | 2 hours |
| Transport | Authorization: Bearer <token> or the x-portal-session-token header |
Every portal-scoped data route validates the token and additionally checks that its tenantId and portalId claims match the request path — a session for one portal cannot read another portal's data, even within the same tenant. Data reads are further scoped to the authenticated traveler's own bookings; see Integration architecture.
POST /api/portal/v1/session/validate lets a portal UI check a stored token (for example on page load) and recover the session claims without making a data call.
Development session mint
POST /api/portal/v1/dev/session-token mints a session directly from caller-supplied claims — a local-development shortcut so UI work doesn't need the full magic-link loop. The endpoint only exists when the service runs in development; it can be forced off there, and can never be enabled in staging or production, where it returns 404.
Embedding and framing
Portal pages are standalone, first-party pages. The service sends X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' on its responses, so a portal cannot be embedded in an iframe on another site. Link to the portal from your website and emails rather than framing it.
Demo portals versus production
Two different things are sometimes both called a "demo", and their authentication posture differs:
- A mock-provider portal (
provider: mockinportal.yaml) is a real portal in every respect — magic-link sign-in, session tokens, and per-traveler data scoping all apply — it just serves seeded fixture data instead of live systems. Use this to evaluate the full product flow. - A tenant UI with embedded simulated data — a portal whose UI source ships its demo data inside the JavaScript bundle and makes no
/api/portal/v1calls. Since no data route is ever hit, no session is required and no sign-in is enforced: the page renders for anyone who has the URL. That is a property of what the tenant UI chose to build, not a platform authentication mode — the moment the same UI fetches from the API, every call requires a valid session.
In production, every trip, payment, document, form, and extras read goes through a session-authenticated API call — there is no unauthenticated data path.