Skip to content

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

OAUTH2_ALLOWED_REDIRECT_URIS='["https://app.example.com","https://admin.example.com"]'
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:

  1. Generates a cryptographically random code_verifier (64 characters, URL-safe).
  2. Derives a code_challenge using the S256 method: Base64url(SHA256(code_verifier)).
  3. Includes code_challenge and code_challenge_method=S256 in the authorization URL sent to the provider.
  4. Stores the code_verifier in 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:

  1. Verifies the state parameter (CSRF protection).
  2. Retrieves the stored code_verifier from the state.
  3. Calls provider.exchange_code(code, code_verifier) — the provider validates the PKCE proof before issuing tokens.
  4. Fetches OAuth2UserInfo using the access token.
  5. Finds or creates a user keyed on email.
  6. Issues a TokenResponse the same way as OTP login.
  7. Redirects the browser to the frontend with the token in the URL fragment (or returns JSON if the Accept header 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
Google 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.