Backend
OAuth and OpenID Connect
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.
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
| Method | Path | Purpose |
|---|---|---|
| GET | /auth/oauth/providers | Public. The enabled providers: [{id, name}]. |
| GET | /auth/oauth/<provider>/start | Browser navigation. ?next=/path&mode=login|link&remember=1. Redirects (302) to the provider. |
| GETPOST | /auth/oauth/<provider>/callback | Where the provider returns. Always ends with a 302 to FRONTEND_PATHS["OAUTH_DONE"]. |
| GET | /auth/oauth/accounts | Linked 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, whenALLOW_SIGNUPandAUTH["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=…:
"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.
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
| Key | Default | Meaning |
|---|---|---|
| PROVIDERS | {} | Enabled providers and their credentials. |
| ALLOW_SIGNUP | True | Create accounts for new identities. |
| CALLBACK_BASE_URL | None | Origin 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.
| Code | Status | When |
|---|---|---|
| oauth_unknown_provider | 404 | The provider is not configured. |
| invalid_mode | 400 | mode is not login or link. |
| not_authenticated | 401 | mode=link without a signed in user. |
| oauth_state_mismatch | redirect | State missing or different: an expired or forged callback. |
| oauth_provider_error | redirect | The provider refused or failed the exchange. |
| oauth_email_unverified | redirect | The provider did not report a verified email. |
| oauth_email_exists | redirect | Another account already uses this email. |
| oauth_signup_disabled | redirect | New identity and ALLOW_SIGNUP is off. |
| signup_disabled | redirect | New identity and AUTH["SIGNUP_ENABLED"] is off. |
| oauth_already_linked | redirect | The identity belongs to another user. |
| account_inactive | redirect | The account is deactivated. |
| last_login_method | 400 | Unlinking would leave the user with no way to sign in. |