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

    @abstractmethod
    async def exchange_code(self, code: str) -> OAuth2UserInfo: ...

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.

The browser flow

Browser                              Craft Easy API                Provider
  │                                       │                            │
  │ GET /auth/oauth2/google/authorize     │                            │
  ├──────────────────────────────────────▶│                            │
  │                                       │  Generates `state`         │
  │ 302 → https://accounts.google.com/... │                            │
  │◀──────────────────────────────────────┤                            │
  │                                                                    │
  │  User logs in at the provider ...                                  │
  │                                                                    │
  │ GET /callback?code=...&state=...                                   │
  ├───────────────────────────────────────────────────────────────────▶│
  │                                       │ exchange_code(code)        │
  │                                       ├───────────────────────────▶│
  │                                       │ 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. Calls provider.exchange_code(code) to obtain OAuth2UserInfo.
  3. Finds or creates a user keyed on email.
  4. Issues a TokenResponse the same way as OTP login.
  5. 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 craft_easy.core.auth.oauth2.base import BaseOAuth2Provider, OAuth2UserInfo

class LinkedInOAuth2Provider(BaseOAuth2Provider):
    name = "linkedin"

    def get_authorization_url(self, state: str) -> str:
        return (
            "https://www.linkedin.com/oauth/v2/authorization"
            f"?response_type=code&client_id={self.client_id}"
            f"&redirect_uri={self.redirect_uri}&state={state}"
            "&scope=r_liteprofile%20r_emailaddress"
        )

    async def exchange_code(self, code: str) -> OAuth2UserInfo:
        async with httpx.AsyncClient() as client:
            token_resp = await client.post(
                "https://www.linkedin.com/oauth/v2/accessToken",
                data={
                    "grant_type": "authorization_code",
                    "code": code,
                    "client_id": self.client_id,
                    "client_secret": self.client_secret,
                    "redirect_uri": self.redirect_uri,
                },
            )
            access_token = token_resp.json()["access_token"]
            headers = {"Authorization": f"Bearer {access_token}"}
            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.