This version is in beta. Some features may change before release.

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 AuthUser and 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

Code
rust
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.

Warning

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:

Code
bash
cargo run -- makemigrations # writes the oauth migration
cargo run -- migrate # creates the oauth_social_account table

Provider 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:

Code
bash
# .env (add this file to .gitignore; never commit credentials)
UMBRAL_OAUTH_GOOGLE_CLIENT_ID=98840861591-xxxx.apps.googleusercontent.com
UMBRAL_OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
UMBRAL_OAUTH_GITHUB_CLIENT_ID=Ov23xxxx
UMBRAL_OAUTH_GITHUB_CLIENT_SECRET=xxxx
 
# optional: public origin for the OAuth callback (defaults to your bind addr)
UMBRAL_OAUTH_REDIRECT_BASE=http://localhost:8100
Warning

The variable names must match exactly. The secret is `UMBRAL_OAUTH_

_CLIENT_SECRET`; note the `CLIENT_`. A common mistake is `UMBRAL_OAUTH_GOOGLE_SECRET`, which `from_env()` never sees; a provider registers only when **both** its id and secret are present, so a misnamed secret means the provider silently doesn't register (you'll get "no providers registered" at boot and the buttons stay hidden).

.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:

RoutePurpose
GET /oauth/{provider}/loginStart a social login
GET /oauth/{provider}/connectConnect a provider (auth required)
GET /oauth/{provider}/callbackProvider redirects back here
POST /oauth/{provider}/disconnectUnlink a provider (auth required)

A login (or connect) entry point is just a link; GET routes need no form:

Code
html
<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:

Code
html
<form method="post" action="/oauth/github/disconnect">
{{ csrf_input }}
<button type="submit">Disconnect</button>
</form>
Warning
If `SecurityPlugin` is mounted (it enforces CSRF on every `POST`) and you omit `{{ csrf_input }}`, the request fails with "CSRF verification failed". This applies to *any* POST form in your app: login, signup, logout, disconnect.

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.

Code
rust
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):

Code
js
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.

Code
js
// SPA callback route
const 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

Code
js
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.

Warning

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:

Code
json
{ "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.

Info

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

If a `SocialAccount` exists for `(provider, provider_uid)`, refresh its tokens and log in as its user. (In connect mode, this is refused if the identity belongs to a *different* user.)

Connect mode

If a logged-in user started a connect flow, attach the identity to them.

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

Otherwise mint a new `AuthUser` (unique username; the password is unusable until they set one).
Warning

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

FieldTypeNotes
userForeignKey<AuthUser>The linked user (cascade delete).
providerString"google" / "github" / ….
provider_uidStringThe provider's stable id. Unique with provider.
provider_emailOption<String>Email the provider reported.
email_verifiedboolWhether the provider verified it.
access_tokenMasked<String>Encrypted at rest.
refresh_tokenOption<Masked<String>>Encrypted; None if not issued.
scopes, expires_atGranted 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):

Code
rust
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 API

Adding a custom provider

Implement OAuthProvider (three methods) and register it like any built-in:

Code
rust
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 redirected code can'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 CSRF state lives 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.

authoauthsocial-logingooglegithub