OAuth2 Providers¶
Craft Easy includes a pluggable OAuth2 stack with ready-to-use providers for Google, Microsoft and GitHub. Custom providers can be added in two files. All providers share the same /auth/oauth2/{provider}/authorize → /auth/oauth2/{provider}/callback flow and return a standard TokenResponse on success.
Built-in providers¶
| Provider | Name | Source module |
|---|---|---|
google |
craft_easy.core.auth.oauth2.google |
|
| Microsoft / Entra ID | microsoft |
craft_easy.core.auth.oauth2.microsoft |
| GitHub | github |
craft_easy.core.auth.oauth2.github |
Each provider class implements the small BaseOAuth2Provider interface (base.py):
class BaseOAuth2Provider(ABC):
name: str
def __init__(self, client_id: str, client_secret: str, redirect_uri: str): ...
@abstractmethod
def get_authorization_url(
self,
state: str,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str: ...
@abstractmethod
async def exchange_code(
self, code: str, code_verifier: str | None = None,
) -> dict: ...
The OAuth2UserInfo dataclass is the normalised result returned by every provider:
@dataclass
class OAuth2UserInfo:
email: str
name: str | None = None
picture: str | None = None
provider: str = ""
provider_user_id: str = ""
raw: dict | None = None
Configuration¶
Add your client credentials to the project settings:
class Settings(CraftEasySettings):
OAUTH2_PROVIDERS: dict = {
"google": {
"client_id": os.environ["GOOGLE_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
"redirect_uri": "https://api.example.com/auth/oauth2/google/callback",
},
"microsoft": {
"client_id": os.environ["MS_CLIENT_ID"],
"client_secret": os.environ["MS_CLIENT_SECRET"],
"redirect_uri": "https://api.example.com/auth/oauth2/microsoft/callback",
},
"github": {
"client_id": os.environ["GH_CLIENT_ID"],
"client_secret": os.environ["GH_CLIENT_SECRET"],
"redirect_uri": "https://api.example.com/auth/oauth2/github/callback",
},
}
Only configured providers are active — calls to an unknown provider return 400.
Allowed redirect URIs¶
When the OAuth2 callback receives a redirect_uri from the client state, it validates the URI against a whitelist before redirecting. This prevents open-redirect attacks where an attacker could steal JWT tokens by injecting an arbitrary URL.
| Setting | Type | Default | Description |
|---|---|---|---|
OAUTH2_ALLOWED_REDIRECT_URIS |
list[str] |
[] |
Allowed URI prefixes. The callback checks that redirect_uri starts with one of these values. An empty list means no client-side redirects are allowed. |
The check uses prefix matching — a value of https://app.example.com allows https://app.example.com/login/callback, https://app.example.com/dashboard, etc.
If the redirect_uri does not match any entry the callback returns HTTP 400 with "redirect_uri is not in the allowed list".
Development mode
When AUTH_ENABLED=false (local development) the whitelist is bypassed and any redirect_uri is accepted. This keeps the local developer experience frictionless while enforcing security in deployed environments.
PKCE (Proof Key for Code Exchange)¶
All OAuth2 flows use PKCE (RFC 7636) automatically. PKCE protects against authorization code interception attacks and is mandatory in OAuth 2.1.
Why PKCE matters
Without PKCE an attacker who intercepts the ?code=... callback URL (via referrer headers, browser history, or a malicious redirect) can exchange that code for tokens. PKCE binds the code to the original request by requiring knowledge of the code_verifier that only the legitimate initiator holds — even if the code is stolen, it is useless without the verifier.
When the /authorize endpoint is called, Craft Easy:
- Generates a cryptographically random
code_verifier(64 characters, URL-safe). - Derives a
code_challengeusing the S256 method:Base64url(SHA256(code_verifier)). - Includes
code_challengeandcode_challenge_method=S256in the authorization URL sent to the provider. - Stores the
code_verifierin the server-side state (not in the browser or URL).
When the callback returns the authorization code, Craft Easy sends the code_verifier to the provider's token endpoint. The provider verifies that SHA256(code_verifier) matches the original code_challenge before issuing tokens.
No configuration is required — PKCE is always enabled. Only the S256 challenge method is accepted; the plain method is rejected by all built-in providers.
PKCE utilities¶
The generate_code_verifier() and generate_code_challenge() functions are available for custom providers:
from craft_easy.core.auth.oauth2 import generate_code_verifier, generate_code_challenge
verifier = generate_code_verifier() # 64-char URL-safe string
challenge = generate_code_challenge(verifier) # Base64url(SHA256(verifier))
The browser flow¶
Browser Craft Easy API Provider
│ │ │
│ GET /auth/oauth2/google/authorize │ │
├──────────────────────────────────────▶│ │
│ │ Generates state, │
│ │ code_verifier, │
│ │ code_challenge (S256) │
│ 302 → https://accounts.google.com/...&code_challenge=... │
│◀──────────────────────────────────────┤ │
│ │
│ User logs in at the provider ... │
│ │
│ GET /callback?code=...&state=... │
├───────────────────────────────────────────────────────────────────▶│
│ │ exchange_code(code, │
│ │ code_verifier) │
│ ├───────────────────────────▶│
│ │ OAuth2UserInfo │
│ │◀───────────────────────────┤
│ 302 → app with token │ │
│◀──────────────────────────────────────┤ │
Kicking off the flow¶
import httpx
# The server returns a 302; httpx follows it automatically
resp = httpx.get(
"http://localhost:8000/auth/oauth2/google/authorize",
follow_redirects=False,
)
print(resp.status_code, resp.headers["location"])
# 307 https://accounts.google.com/o/oauth2/v2/auth?...&state=...
In a browser context, simply link the user to /auth/oauth2/google/authorize — the API responds with a redirect.
The callback¶
When the provider redirects back with ?code=...&state=..., the API:
- Verifies the
stateparameter (CSRF protection). - Retrieves the stored
code_verifierfrom the state. - Calls
provider.exchange_code(code, code_verifier)— the provider validates the PKCE proof before issuing tokens. - Fetches
OAuth2UserInfousing the access token. - Finds or creates a user keyed on
email. - Issues a
TokenResponsethe same way as OTP login. - Redirects the browser to the frontend with the token in the URL fragment (or returns JSON if the
Acceptheader requests it).
Registering a custom provider¶
Two steps:
1. Implement BaseOAuth2Provider:
# my_project/auth/linkedin.py
import httpx
from urllib.parse import urlencode
from craft_easy.core.auth.oauth2.base import BaseOAuth2Provider, OAuth2UserInfo
class LinkedInOAuth2Provider(BaseOAuth2Provider):
name = "linkedin"
def get_authorization_url(
self,
state: str,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str:
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"state": state,
"scope": "r_liteprofile r_emailaddress",
}
if code_challenge and code_challenge_method:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = code_challenge_method
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
async def exchange_code(self, code: str, code_verifier: str | None = None) -> dict:
data = {
"grant_type": "authorization_code",
"code": code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
}
if code_verifier:
data["code_verifier"] = code_verifier
async with httpx.AsyncClient() as client:
token_resp = await client.post(
"https://www.linkedin.com/oauth/v2/accessToken", data=data,
)
return token_resp.json()
async def get_user_info(self, access_token: str) -> OAuth2UserInfo:
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient() as client:
profile = (await client.get(
"https://api.linkedin.com/v2/me", headers=headers
)).json()
email = (await client.get(
"https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))",
headers=headers,
)).json()
return OAuth2UserInfo(
email=email["elements"][0]["handle~"]["emailAddress"],
name=f"{profile.get('localizedFirstName', '')} {profile.get('localizedLastName', '')}".strip(),
provider="linkedin",
provider_user_id=profile["id"],
raw=profile,
)
2. Register it at startup, before the app begins accepting requests:
# app.py
from craft_easy.core.auth.oauth2 import register_provider
from my_project.auth.linkedin import LinkedInOAuth2Provider
register_provider("linkedin", LinkedInOAuth2Provider)
Add the LinkedIn credentials to OAUTH2_PROVIDERS and the new flow is immediately available at /auth/oauth2/linkedin/authorize.
Provider matrix¶
| Provider | Scopes used | User identifier |
|---|---|---|
openid email profile |
sub |
|
| Microsoft | openid email profile User.Read |
id |
| GitHub | read:user user:email |
id (numeric) |
All three providers normalise to the same OAuth2UserInfo, so the downstream login logic is identical regardless of which provider the user picked.
Endpoints reference¶
| Method | Path | Purpose |
|---|---|---|
GET |
/auth/oauth2/{provider}/authorize |
Start the flow — 302 redirect to the provider. |
GET |
/auth/oauth2/{provider}/callback |
Exchange the authorization code for a token. |