Why this design
We picked Auth.js + Aurora over Cognito so the contributor UX stays inside our own pages (no hosted-UI redirect). Magic-link is the primary first-touch — passwordless, no reset flows, no breaches — with optional email + password as a faster returning-user path. ORCID is intentionally not a primary auth provider; it's a post-signup linking step that flips a contributor's operator_class to researcher and crosses the trust threshold, so their data trains the public calibration model.
Session model
- Access JWT (HS256, 15-min lifetime, signed with a 64-byte secret in Secrets Manager) — sent in an
HttpOnly; Secure; SameSite=Nonecookie namedmud2dust_access. Verified per request without a DB read. - Refresh token (32-byte URL-safe random, 30-day lifetime) — also
HttpOnly; Secure; SameSite=None, namedmud2dust_refresh. Hash stored in thesessionstable; un-hashed value lives only in the cookie. - Rotation + replay-detection. Every successful
POST /v1/auth/refreshmints a child session and marks the parentrevoked_at = NOW(). If the parent token is ever presented again (theft, a replayed request), a recursive CTE walks the family and revokes every descendant — both the attacker and the legit user are kicked out, prompting the legit user to sign in fresh. - Sign out revokes the session family the same way.
Sign-in: magic link
POST /v1/auth/request-magic-link
body: { email }
→ 202 { status: "sent" }
POST /v1/auth/verify-magic-link
body: { token,
set_password?: string >= 8,
set_username?: string matching /^[A-Za-z][A-Za-z0-9_-]{2,31}$/ }
→ 200 {
user_id, contributor_id, email, is_new_user,
password_set, username_set, username_error,
access_token, access_token_expires_in, session_id
}
+ Set-Cookie: mud2dust_access=…; mud2dust_refresh=…
(HttpOnly; Secure; SameSite=None; Path=/)request-magic-link always returns 202 regardless of whether the email is on file or SES delivers — anti-enumeration. Internal failures log to CloudWatch. The link itself expires in 15 minutes and is single-use; the verify endpoint atomically marks the token consumed.
The optional set_password + set_username fields land both on the same call — the typical first-time onboarding flow. Both are pre-validated before consuming the magic-link so a typo doesn't burn the token. A username collision returns 200 with username_set: false and username_error: "taken" so the UI can prompt for an alternate.
Sign-in: email + password
POST /v1/auth/sign-in-with-password
body: { email, password }
→ 200 { user_id, contributor_id, email,
access_token, access_token_expires_in, session_id }
+ Set-Cookie cookies as aboveConstant-time-ish: the server runs bcrypt.compare against a fixed decoy hash even on unknown email, so timing doesn't leak email enumeration. 401 on miss.
Refresh + sign-out
POST /v1/auth/refresh
cookie: mud2dust_refresh=…
→ 200 { user_id, contributor_id, access_token,
access_token_expires_in, session_id }
+ new Set-Cookie pair, parent revoked
POST /v1/auth/sign-out
cookie: mud2dust_refresh=…
→ 200 { status: "signed_out" }
+ Set-Cookie clearing both cookiesThe signed-in user: /v1/me
GET /v1/me
→ 200 {
user_id, contributor_id, email, username, display_name,
has_password, station_count,
orcid: { orcid_id, is_researcher, last_verified_at } | null
}Profile + password + email change
PATCH /v1/me
body: { username?, display_name? }
→ 200 (returns the updated /v1/me shape)
errors: 409 username_taken · 400 invalid_username
POST /v1/me/password
body: { current_password?, new_password }
→ 200 { status: "ok" }
errors: 400 current_password_required · 401 wrong_current_password
POST /v1/me/email/request-change
body: { new_email }
→ 202 { status: "sent" } (verification link to NEW email)
errors: 409 email_taken · 400 email_unchanged
POST /v1/me/email/confirm-change (token-gated, no auth required)
body: { token }
→ 200 { status: "ok", new_email }
errors: 401 invalid_or_expired_token · 409 email_takenEmail change is two-step on purpose — the current email keeps working until the user clicks the verification link sent to the new email. A typo or hijacked input doesn't lock anyone out. updated_at is touched on every change.
ORCID linking → researcher tier
GET /v1/me/orcid/start?redirect_uri=…
→ 200 { authorize_url, state }
+ Set-Cookie: mud2dust_orcid_state=… (CSRF, 10-min TTL)
POST /v1/me/orcid/link
body: { code, redirect_uri, state }
→ 200 {
orcid_id, is_researcher, affiliations, stations_updated
}Behind the scenes: the link endpoint exchanges the OAuth code for an ORCID access token, calls ORCID's Public API to read the user's employments + educations, and runs verifyResearcherTier — true if any well-formed institutional affiliation exists (current or past). Result is upserted into orcid_identities; if is_researcher is true, every station the contributor owns has its operator_class bumped to researcher, which crosses the training-tier threshold and makes that contributor's data part of the nightly retrain (P4.22).
CORS
The API is locked to https://mud2dust.com and https://mud2dust.dev as origins, with Access-Control-Allow-Credentials: true and methods GET POST PUT PATCH DELETE OPTIONS. Cross-domain fetches between mud2dust.com and api.mud2dust.io require SameSite=None cookies — that's why the access / refresh cookies use None in production (they require Secure; production uses TLS so this is fine).
Errors you might see
| Status | Body | Meaning |
|---|---|---|
| 401 | auth_required | No JWT cookie / Bearer token presented |
| 401 | invalid_or_expired_token | JWT bad signature / expired / wrong issuer |
| 401 | invalid_credentials | Email + password didn't match |
| 401 | invalid_or_expired_refresh | Refresh token unknown / expired / replayed (cookies cleared, user must sign in fresh) |
| 409 | username_taken | Username collision on PATCH /v1/me |
| 409 | email_taken | Email already on another account |