TILLAUTH · FLOWS

OAuth · your branding, our plumbing.

Each TillAuth app brings its own Google + GitHub client IDs. End-users see your name on the consent screen, not "TillDev". We handle the flow, the PKCE, the token exchange, the identity row.

Why per-app client IDs

If we used one OAuth app per TillAuth instance, every customer's end-user would see "TillDev" on the Google consent screen. That's awful. So each of your apps configures its own Google + GitHub developer-console clients, and TillAuth stores them per-app.

Client secrets are AES-256-GCM-encrypted with the app's DEK and decrypted only at token-exchange time. They never leave the backend.

Setting it up

  1. Google: create an OAuth client in the Google Cloud Console. Authorized JavaScript origins:https://<slug>.tilldev.app (and any custom domain). Authorized redirect URIs:https://<slug>.tilldev.app/v1/oauth/google/callback.
  2. GitHub: create an OAuth app atgithub.com/settings/developers. Authorization callback URL:https://<slug>.tilldev.app/v1/oauth/github/callback.
  3. In your TillAuth admin → Providers tab, paste the client ID and secret. Flip enabled. We persist the row in tillauth_app_oauth with the secret encrypted.

That's it. The provider chip appears on the hosted-login page; SDK users get a useOAuth() hook with the provider exposed.

The flow

  1. Client calls GET /v1/oauth/[provider]/start?redirect_uri=…. We mint a PKCE code_verifier, derive the challenge, and redirect the browser to the provider's authorize endpoint with state = signed JWT(redirect_uri, ip_prefix, ts). Audit row: signin.oauth.started.
  2. Provider bounces the user to /v1/oauth/[provider]/callback?code=…&state=…. We verify the state JWT (bound to IP /24, freshness), exchange the code for tokens via PKCE, fetch the userinfo claims.
  3. We look up tillauth_identities by (app_id, provider, provider_subject). If found → mint session for the linked user. If not found but a user with the same email exists → link the identity onto that user (configurable per-app). Otherwise → create a new user.
  4. Audit row: signin.oauth.ok with metadata.provider.
  5. Browser is redirected back to your redirect_uri with a session cookie set.

Linking identities to existing users

Default behaviour: if a user signed up with email + password and later clicks "Sign in with Google" for the same email, we link the Google identity onto the existing user — after verifying the email claim is verified by Google. Unverified-email providers (some edge-case GitHub configs) refuse to auto-link.

Per-app override:settings.oauth_auto_link = false forces a separate flow: the user has to be signed in first, then explicitly link the provider from their settings.

Errors

  • Provider denied / user cancelled → bounce back with ?error=access_denied; audit signin.oauth.error with the provider's reason.
  • State mismatch / expired → 410, no session minted. Likely an open-tab-too-long scenario.
  • Identity links to a different user → 409, the user can choose to sign in to that account instead.

What about other providers

Google and GitHub are the only first-class providers today; anything else (Apple, Microsoft, Slack, LinkedIn) goes through OIDC SSO — a generic provider where you bring the issuer URL.


Next: OIDC SSO for everything else · or the audit log if you want to see how every successful + failed signin is recorded.