OAuth (Sign in with Google)¶
regstack ships an opt-in OAuth subsystem. v1 supports Google; the
abstraction is shaped so adding GitHub / Microsoft / Apple later is a
new module under regstack/oauth/providers/ plus one config field.
This page walks a host through enabling it. The full design — including
the threat model and the four-milestone build sequence the
implementation followed — is in
tasks/oauth-design.md.
What you get¶
When OAuth is enabled and at least one provider is configured:
Five JSON endpoints under
/api/auth/oauth/:GET /oauth/{provider}/start— public; redirects to the provider.GET /oauth/{provider}/callback— public; handles the redirect back.POST /oauth/exchange— single-use; SPA trades the state-id for a session JWT.POST /oauth/{provider}/link/start— authenticated; returns the URL to navigate the browser to.DELETE /oauth/{provider}/link— authenticated; unlinks one identity.GET /oauth/providers— authenticated; lists configured + linked providers (drives the SSR connected-accounts panel).
A “Sign in with Google” button on the bundled SSR login page.
A “Connected accounts” panel on the SSR
/account/mepage.Four hook events:
oauth_signin_started,oauth_signin_completed,oauth_account_linked,oauth_account_unlinked.
Install the extra¶
uv add 'regstack[oauth]'
The oauth extra pulls in pyjwt[crypto]>=2.8, which transitively
includes cryptography. ID-token signature verification needs RSA, so
this is unavoidable.
Register a Google client¶
In the Google Cloud Console:
Create an OAuth 2.0 Client ID of type Web application.
Add an Authorized redirect URI that exactly matches the URL regstack will receive callbacks at — by default that’s
<your base_url><api_prefix>/oauth/google/callback. For a local dev server with the defaults that’shttp://localhost:8000/api/auth/oauth/google/callback.Copy the client ID and client secret out — you’ll set them on regstack next.
Configure regstack¶
The fastest path is the OAuth setup wizard, which opens a native
window and walks you through every step (registering the GCP client,
pasting the redirect URI, picking your linking policy) and finally
merges the credentials into your existing regstack.toml and
regstack.secrets.env without disturbing other settings:
uv run regstack oauth setup
The wizard is non-clobbering — it preserves comments, unrelated
top-level keys, and unrelated tables ([email], [sms], etc.). Re-run
it any time you need to rotate credentials or change the linking
policy. On a headless host (CI, server) use
regstack oauth setup --headless --client-id=… --client-secret=…
to get the same merge with no GUI (pair with --dry-run for a
preview that does not touch the files).
If you’d rather edit by hand, the resulting files look like:
# regstack.toml
enable_oauth = true
[oauth]
google_client_id = "12345.apps.googleusercontent.com"
# google_client_secret lives in regstack.secrets.env
# google_redirect_uri = "https://your.app/api/auth/oauth/google/callback" # optional override
auto_link_verified_emails = false # security choice — see below
# regstack.secrets.env
REGSTACK_OAUTH__GOOGLE_CLIENT_SECRET=...
The router is mounted only when enable_oauth=true AND
google_client_id AND google_client_secret are all set.
The account-linking decision¶
When a Google sign-in arrives carrying an email that already belongs to a regstack user (created via password registration), regstack has to choose between three policies:
Policy |
Behaviour |
|---|---|
Refuse (default) |
Return |
Auto-link verified |
If Google’s |
Always create new |
Make a second account. |
regstack defaults to refuse. To opt into auto-linking — accepting
that an attacker who later acquires a recycled Gmail address could sign
in as the original regstack user — set
oauth.auto_link_verified_emails = true.
The full threat-model writeup is in
tasks/oauth-design.md.
OAuth-only users¶
A Google sign-up creates a regstack user with hashed_password = None.
Three knock-on effects, all handled:
Login route rejects password attempts on these accounts with the same generic 401 a wrong-password attempt gets — never reveal that an account exists but has no password.
change-password/change-email/delete-accountall need the current password. For OAuth-only users they return 400 with a pointer at the password-reset flow, which doubles as a “set initial password” path.DELETE /oauth/{provider}/linkrefuses if it would remove the user’s only sign-in method (no password set, no other linked provider). The error is400 last sign-in method.
Hooks¶
@regstack.on("oauth_signin_completed")
async def _track_signin(*, user, provider, mode, was_new):
if was_new:
await analytics.track("signup", {"user": user.id, "provider": provider})
else:
await analytics.track("login", {"user": user.id, "provider": provider})
@regstack.on("oauth_account_linked")
async def _notify_link(*, user, provider):
await mailer.send_link_notification(to=user.email, provider=provider)
The full event list is in the architecture guide.
Disabling OAuth¶
Flip enable_oauth = false (or leave the credentials unset). The
router won’t mount; the SSR login page won’t render the button; the
/me panel hides the section. No other configuration changes are
required.