Authentication specification

Status: Google OIDC source candidate; email/password remains the deployed baseline.

Product contract

Soldi supports email/password authentication and may expose Google only when the current Worker has a complete, exact provider configuration. “Sign in” and “Join Soldi” are distinct:

  • Sign in: a Google sub must already exist in oauth_identities. Email equality is never sign-in or linking authority.
  • Join Soldi: the current TERMS_VERSION acceptance is signed into the outbound state before leaving Soldi. A new account starts with zero purchased and promotional cents, remains unactivated until $500 of verified Stripe funding, and receives a random PBKDF2 password sentinel that cannot be used for password login. The existing recovery flow is the supported way to establish a password.
  • Existing-email collision: Soldi creates no account and inserts no identity. It issues a five-minute, HttpOnly signed link intent; the user must verify the existing account password before Soldi inserts the Google identity.

OIDC security contract

  • Server-side Authorization Code flow, scopes exactly openid email profile.
  • Exact environment redirect URI, signed ten-minute state, OIDC nonce, PKCE S256, and a separately signed same-browser HttpOnly flow cookie.
  • Redirect targets pass the shared same-origin sanitizeNextPath boundary before signing.
  • ID tokens are verified with jose against Google JWKS, accepted Google issuers, the configured client ID audience, expiry, nonce, immutable sub, and boolean email_verified.
  • Callback state_id and SHA-256 code claims are inserted uniquely before exchange. Duplicate state, callback, or code use fails closed.
  • Google access, refresh, and ID tokens are never stored or placed in a Soldi SPA URL. oauth_identities stores only provider, provider subject, Soldi user, verified-email snapshot, and timestamps.
  • Successful OAuth authentication issues the same signed session and CSRF cookies as password authentication. Demo and fixture identity rejection remains active in production.

Configuration contract

The readiness endpoint is GET /api/v1/auth/oauth/readiness. It returns enabled only when SESSION_SECRET, GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, and the exact redirect URI are present.

  • Staging: https://staging.soldi.cc/api/v1/auth/oauth/google/callback
  • Production: https://app.soldi.cc/api/v1/auth/oauth/google/callback
  • Development: an explicitly configured http://localhost or loopback callback at the same path

The client ID and client secret are controller-required Cloudflare secret bindings. The redirect URI is a checked-in exact plain-text binding. Configuration names in source are not provider or deployment proof.

Data contract

Migration 0046_oauth_identities.sql creates:

  • oauth_identities, unique by (provider, provider_subject) and (user_id, provider), with a user lookup index.
  • oauth_callback_claims, unique by signed state ID and SHA-256 code hash, with an expiry index.

No existing user, wallet, activation, consent, or password-recovery column is replaced.

Acceptance gates

  • Protocol tests cover linked sign-in, terms-stamped signup, terms rejection, explicit password linking, state/code replay, state mismatch/expiry, nonce, issuer, audience, verified email, redirects, configuration, and token non-persistence.
  • Login source tests cover fail-closed visibility, Sign in/Join separation, terms gating, and the explicit link prompt.
  • Local browser acceptance covers Sign in and Join at 390×844 and 1440×900. It is source/UI evidence only; no live Google login is required.
  • Protected staging proof begins only after Cameron completes the provider checklist in docs/runbooks/google-oauth.md and authorizes the exact controller path.