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
- 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. - GitHub: create an OAuth app at
github.com/settings/developers. Authorization callback URL:https://<slug>.tilldev.app/v1/oauth/github/callback. - In your TillAuth admin → Providers tab, paste the client ID and secret. Flip enabled. We persist the row in
tillauth_app_oauthwith 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
- Client calls
GET /v1/oauth/[provider]/start?redirect_uri=…. We mint a PKCEcode_verifier, derive the challenge, and redirect the browser to the provider's authorize endpoint withstate = signed JWT(redirect_uri, ip_prefix, ts). Audit row:signin.oauth.started. - 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. - We look up
tillauth_identitiesby(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. - Audit row:
signin.oauth.okwithmetadata.provider. - Browser is redirected back to your
redirect_uriwith 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; auditsignin.oauth.errorwith 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.