OAuth / social login
Sign in with Google or GitHub and connect external accounts to a user, with provider tokens encrypted at rest.
OAuth / social login
umbral-oauth adds social login and OAuth account connection, layered on umbral-auth. It does two things with one flow:
- Social login: "Sign in with Google" resolves (or creates) an
AuthUserand logs them in. - Account connection: a logged-in user attaches a provider ("Connect GitHub"), which is how the app later gets API access (Drive, repos, …) on their behalf.
A social identity is an extension of the user (a SocialAccount row linked by foreign key), never a replacement for the username. A user can link several accounts (the unique key is (provider, provider_uid), so even two GitHub accounts on one user are fine). Provider tokens are stored in Masked columns, encrypted at rest.
Wiring
use umbral_oauth::OAuthPlugin;use umbral_oauth::providers::{GoogleProvider, GitHubProvider}; let mut oauth = OAuthPlugin::new("https://example.com").login_redirect("/dashboard");if let Some(g) = GoogleProvider::from_env() { oauth = oauth.provider(g); }if let Some(gh) = GitHubProvider::from_env() { oauth = oauth.provider(gh); } App::builder() .plugin(AuthPlugin::<AuthUser>::default()) .plugin(SessionsPlugin::default()) // required: the flow stores CSRF state in the session .plugin(oauth)OAuthPlugin::new(redirect_base) takes your app's public origin (https://example.com, or http://localhost:8000 in dev). Each provider's callback is {redirect_base}/oauth/{provider}/callback, which must match what you register in the provider's console. .login_redirect(path) is where the browser lands after a successful login (default /). Using from_env() means a provider with no credentials configured is simply skipped, so it's safe to leave the plugin on with nothing set.
SessionsPlugin is required: the flow stores its CSRF state token in the session. Without it, every callback fails the state check.
Create the table
SocialAccount is a model like any other, so generate and apply its migration:
cargo run -- makemigrations # writes the oauth migrationcargo run -- migrate # creates the oauth_social_account tableProvider setup
Register an OAuth app with each provider and set the credentials as environment variables.
Optional: override the callback origin in prod without recompiling. The website example reads UMBRAL_OAUTH_REDIRECT_BASE.
Using a .env file
You don't have to export the variables by hand. Drop them in a .env file in the directory you launch the app from (next to Cargo.toml), and Umbral loads it into the process environment at App::builder(), so the providers' from_env() picks them up:
# .env (add this file to .gitignore; never commit credentials)UMBRAL_OAUTH_GOOGLE_CLIENT_ID=98840861591-xxxx.apps.googleusercontent.comUMBRAL_OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxUMBRAL_OAUTH_GITHUB_CLIENT_ID=Ov23xxxxUMBRAL_OAUTH_GITHUB_CLIENT_SECRET=xxxx # optional: public origin for the OAuth callback (defaults to your bind addr)UMBRAL_OAUTH_REDIRECT_BASE=http://localhost:8100The variable names must match exactly. The secret is `UMBRAL_OAUTH_
.env is read relative to the working directory, so launch the app from the project root (where the .env lives). Real exported environment variables always win over .env values.
Routes
The plugin mounts, per registered provider:
| Route | Purpose |
|---|---|
GET /oauth/{provider}/login | Start a social login |
GET /oauth/{provider}/connect | Connect a provider (auth required) |
GET /oauth/{provider}/callback | Provider redirects back here |
POST /oauth/{provider}/disconnect | Unlink a provider (auth required) |
A login (or connect) entry point is just a link; GET routes need no form:
<a href="/oauth/google/login">Sign in with Google</a><a href="/oauth/github/login">Sign in with GitHub</a> <!-- For a logged-in user, on a profile page: --><a href="/oauth/github/connect">Connect GitHub</a>Disconnect is a POST, so include the CSRF token. Umbral exposes {{ csrf_input }}, a ready-made, safe hidden <input name="csrf_token"> (the same one the admin uses) that's available in every template render when the security middleware is active:
<form method="post" action="/oauth/github/disconnect"> {{ csrf_input }} <button type="submit">Disconnect</button></form>SPA login (bearer token)
The flow above ends in a cookie session, perfect for a server-rendered app. A single-page app on a different origin (Vite, Next, a mobile client) can use the same routes and get back a bearer token instead, so it can call the API with Authorization: Bearer.
Allowlist the SPA's return URL
Add the URL your SPA handles the callback on. Only URLs starting with an allowlisted prefix are honored; this is the open-redirect / token-theft defense.
let oauth = OAuthPlugin::new("https://api.example.com") .allow_return("https://app.example.com/auth/callback") .provider(GoogleProvider::from_env().unwrap());Send the browser to login with ?next=
From the SPA, do a full-page navigation (not fetch; OAuth uses redirects):
window.location = "https://api.example.com/oauth/google/login" + "?next=https://app.example.com/auth/callback";Read the token from the URL fragment
After consent, the callback redirects to …/auth/callback#token=umbral_…&token_type=Bearer. The token rides in the URL fragment, which browsers never send to a server and proxies never log.
// SPA callback routeconst params = new URLSearchParams(location.hash.slice(1));const token = params.get("token");if (token) { localStorage.setItem("umbral_token", token); history.replaceState(null, "", location.pathname); // scrub the fragment}Call the API with the token
fetch("https://api.example.com/api/post/", { headers: { Authorization: `Bearer ${localStorage.getItem("umbral_token")}` },});umbral-rest's bearer authentication resolves it: the token is a real AuthToken row, so it works everywhere bearer auth does and can be revoked.
A ?next= that doesn't match an allow_return prefix is rejected with 400, so an attacker can't point the flow at their own origin to harvest a freshly minted token. With no allow_return configured, ?next= is ignored entirely and the flow keeps its server-rendered session behavior (safe by default). Token mode is login-only: a connect flow never mints a token (the user already holds one).
Endpoint discovery
GET /oauth/providers returns the configured providers and their flow URLs, auto-built from what's registered, so a SPA renders its buttons without hardcoding paths:
{ "providers": [ { "key": "google", "label": "Google", "login": { "path": "/oauth/google/login", "url": "https://api.example.com/oauth/google/login" }, "connect": { "path": "/oauth/google/connect", "url": "https://api.example.com/oauth/google/connect" }, "callback": { "path": "/oauth/google/callback", "url": "https://api.example.com/oauth/google/callback" } } ] }path is relative; url is absolute (joined onto the redirect_base). The endpoint is public; it lists provider names only, never secrets.
If you also run umbral-rest, these same login / connect endpoints show up in its GET /api/ root under endpoints, next to your REST resources, one index for the whole API. The plugin advertises them through the framework's Plugin::api_endpoints() seam, so REST lists them without depending on umbral-oauth.
Linking policy
On the callback, the resolved identity is matched in order:
Already linked
Connect mode
Verified-email link
If the provider asserts a verified email matching an existing user, link to that user. Only when verified; an unverified address can't capture an account.
Auto-create
Email-based auto-linking happens only for provider-verified emails. This prevents account takeover: an attacker can't pre-register an unverified address to capture a future real signup.
The SocialAccount model
| Field | Type | Notes |
|---|---|---|
user | ForeignKey<AuthUser> | The linked user (cascade delete). |
provider | String | "google" / "github" / …. |
provider_uid | String | The provider's stable id. Unique with provider. |
provider_email | Option<String> | Email the provider reported. |
email_verified | bool | Whether the provider verified it. |
access_token | Masked<String> | Encrypted at rest. |
refresh_token | Option<Masked<String>> | Encrypted; None if not issued. |
scopes, expires_at | Granted scopes + token expiry. |
Using stored tokens (API access)
The tokens are exactly what you need to call the provider's API later (e.g. Google Drive). Load the account and .reveal() the token (requires the mask private key):
use umbral_oauth::models::{SocialAccount, social_account}; let account = SocialAccount::objects() .filter(social_account::USER.eq(user_id)) .filter(social_account::PROVIDER.eq("google")) .first().await?.unwrap(); let access_token = account.access_token.reveal()?;// → bearer token for the Google Drive APIAdding a custom provider
Implement OAuthProvider (three methods) and register it like any built-in:
use umbral_oauth::provider::{Identity, OAuthError, OAuthProvider, TokenSet}; #[async_trait::async_trait]impl OAuthProvider for GitLabProvider { fn key(&self) -> &'static str { "gitlab" } fn label(&self) -> &'static str { "GitLab" } // `code_challenge` and `code_verifier` are PKCE (see below): forward // `code_challenge` (+ `code_challenge_method=S256`) on the authorize // URL and `code_verifier` on the token POST. fn authorize_url(&self, state: &str, redirect_uri: &str, code_challenge: &str) -> String { /* build URL */ } async fn exchange_code(&self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<TokenSet, OAuthError> { /* POST token endpoint */ } async fn fetch_identity(&self, tokens: &TokenSet) -> Result<Identity, OAuthError> { /* GET userinfo */ }}Identity { uid, email, email_verified, display_name } is what the policy consumes; set email_verified honestly, since it gates auto-linking.
How the flow is hardened
The framework wires three protections into every flow, so you don't have to:
- PKCE (RFC 7636), always S256. Each flow mints a random
code_verifier, persists it in the session, and sends only its SHA-256 hash (code_challenge) on the authorize redirect. The token exchange replays the verifier, so an attacker who intercepts the redirectedcodecan't redeem it. Sending the challenge is harmless even against a provider that doesn't enforce PKCE - RFC 6749 §3.1 requires servers to ignore unrecognized parameters. A custom provider gets the challenge/verifier as method arguments (above); a provider that doesn't support PKCE can simply not append them. - Single-use
state. The CSRFstatelives in the session and is consumed on the callback, so a captured callback URL can't be replayed. - Expiring
state. The flow rides the session, so it expires with the session TTL.
Troubleshooting
redirect_uri_mismatch / invalid redirect
The callback URL registered in the provider console must exactly equal {redirect_base}/oauth/{provider}/callback, including scheme and trailing-slash rules. Check the redirect_base you passed to OAuthPlugin::new.
oauth state mismatch (400)
The CSRF state in the session didn't match the callback. Usually means SessionsPlugin isn't mounted, the session cookie was dropped between login and callback (third-party cookie / SameSite issues across origins), or the user took too long. Confirm sessions work for a normal login first.
No refresh token from Google
Google only returns a refresh token on the first consent unless prompt=consent forces it (which umbral sets). If you'd already consented, revoke the app's access in your Google account and re-link.
unknown provider (404)
The provider wasn't registered: from_env() returned None because its UMBRAL_OAUTH_<PROVIDER>_CLIENT_ID / _CLIENT_SECRET aren't set.
Design rationale
See docs/superpowers/specs/2026-06-13-masked-and-oauth-design.md (Component B) for the provider abstraction, the reqwest-over-oauth2-crate choice, and the policy's security reasoning.