Backend

OAuth and OpenID Connect

Sign in with Google, GitHub, Apple or any OpenID Connect provider, with PKCE, account linking and the 2FA step.

pleate_backend.oauth signs users in with Google, GitHub, Apple or any OpenID Connect provider (Okta, Auth0, Keycloak, Microsoft Entra...). It is mounted at /api/auth/oauth. The flow is a full page redirect with state, nonce and a PKCE (S256) verifier kept in the session; no token ever reaches the browser.

Setup

Only the providers listed in PROVIDERS are enabled. Register the callback URL at the provider: https://<your site>/api/auth/oauth/<provider>/callback.

settings.py
PLEATE["OAUTH"] = {
    "PROVIDERS": {
        "google": {"client_id": env("GOOGLE_CLIENT_ID"), "client_secret": env("GOOGLE_CLIENT_SECRET")},
        "github": {"client_id": env("GITHUB_CLIENT_ID"), "client_secret": env("GITHUB_CLIENT_SECRET")},
        "apple": {
            "client_id": "com.plate.web", "team_id": "ABCDE12345", "key_id": "XYZ987",
            "private_key": env("APPLE_PRIVATE_KEY"),
        },
        "okta": {
            "type": "oidc", "name": "Okta",
            "client_id": env("OKTA_CLIENT_ID"), "client_secret": env("OKTA_CLIENT_SECRET"),
            "discovery_url": "https://plate.okta.com/.well-known/openid-configuration",
            "scopes": ["openid", "email", "profile"],
        },
    },
    "ALLOW_SIGNUP": True,
}

type picks the provider class and defaults to the key, so several OIDC providers can live side by side. Every entry accepts name and scopes. New types are added with pleate_backend.oauth.providers.register_provider.

Endpoints

MethodPathPurpose
GET/auth/oauth/providersPublic. The enabled providers: [{id, name}].
GET/auth/oauth/<provider>/startBrowser navigation. ?next=/path&mode=login|link&remember=1. Redirects (302) to the provider.
GETPOST/auth/oauth/<provider>/callbackWhere the provider returns. Always ends with a 302 to FRONTEND_PATHS["OAUTH_DONE"].
GET/auth/oauth/accountsLinked accounts: [{id, provider, email, created_at, last_login_at}].
DELETE/auth/oauth/accounts/<id>Unlinks one. 204.

next must be a path on the frontend; anything else becomes /, so the flow cannot be turned into an open redirect. Apple answers with a POST (form_post); the callback converts it into a 303 to itself with GET so the SameSite=Lax session cookie travels.

What the callback does

  • Known identity: signs in. If the user has 2FA the pending login is stored and the redirect carries status=mfa_required.
  • New identity: the provider must report a verified email. If an account already uses it, the result is oauth_email_exists: accounts are never linked automatically, the user signs in with the password and links from the settings. Otherwise a user is created, without a password and with the email verified, when ALLOW_SIGNUP and AUTH["SIGNUP_ENABLED"] are on.
  • mode=link: attaches the identity to the signed in user.

The frontend page at OAUTH_DONE (default /auth/oauth/done) reads ?status=ok|error|mfa_required&code=…&next=…:

app/auth/oauth/done/page.tsx
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { TwoFactorForm } from "pleate/auth/two-factor-form";
import { useLogin } from "pleate-client/react";

export default function OAuthDone() {
  const params = useSearchParams();
  const router = useRouter();
  const next = params.get("next") ?? "/";
  const login = useLogin({ onSuccess: () => router.replace(next) });

  switch (params.get("status")) {
    case "ok":
      router.replace(next);
      return null;
    case "mfa_required":
      return <TwoFactorForm {...login.twoFactorFormProps} />;
    default:
      return <p>Sign in failed ({params.get("code")}).</p>;
  }
}

The buttons come from useOAuth(): each provider becomes a link to its start URL.

tsx
const oauth = useOAuth({ next: "/dashboard", remember: true });
<LoginForm {...login.loginFormProps} oauth={<OAuthButtons {...oauth.oauthButtonsProps} layout="row" />} />

// Settings page: link another provider, list and unlink
const link = useOAuth({ mode: "link", next: "/settings/security" });
const accounts = useSocialAccounts();   // accounts.data, accounts.unlink.mutate(id)

Settings

KeyDefaultMeaning
PROVIDERS{}Enabled providers and their credentials.
ALLOW_SIGNUPTrueCreate accounts for new identities.
CALLBACK_BASE_URLNoneOrigin of the callback registered at the provider, e.g. https://api.plate.app. Defaults to the request origin.

Error codes

Returned as code on the redirect, or as JSON errors from start and the account endpoints.

CodeStatusWhen
oauth_unknown_provider404The provider is not configured.
invalid_mode400mode is not login or link.
not_authenticated401mode=link without a signed in user.
oauth_state_mismatchredirectState missing or different: an expired or forged callback.
oauth_provider_errorredirectThe provider refused or failed the exchange.
oauth_email_unverifiedredirectThe provider did not report a verified email.
oauth_email_existsredirectAnother account already uses this email.
oauth_signup_disabledredirectNew identity and ALLOW_SIGNUP is off.
signup_disabledredirectNew identity and AUTH["SIGNUP_ENABLED"] is off.
oauth_already_linkedredirectThe identity belongs to another user.
account_inactiveredirectThe account is deactivated.
last_login_method400Unlinking would leave the user with no way to sign in.