Auth

Sign-in, session, and account-management endpoints exposed by api.mud2dust.io. Production-live as of 2026-05-08; the UI for these flows is at mud2dust.com/auth/sign-in/.

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=None cookie named mud2dust_access. Verified per request without a DB read.
  • Refresh token (32-byte URL-safe random, 30-day lifetime) — also HttpOnly; Secure; SameSite=None, named mud2dust_refresh. Hash stored in the sessions table; un-hashed value lives only in the cookie.
  • Rotation + replay-detection. Every successful POST /v1/auth/refresh mints a child session and marks the parent revoked_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 above

Constant-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 cookies

The 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_taken

Email 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

StatusBodyMeaning
401auth_requiredNo JWT cookie / Bearer token presented
401invalid_or_expired_tokenJWT bad signature / expired / wrong issuer
401invalid_credentialsEmail + password didn't match
401invalid_or_expired_refreshRefresh token unknown / expired / replayed (cookies cleared, user must sign in fresh)
409username_takenUsername collision on PATCH /v1/me
409email_takenEmail already on another account