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) -> 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:
- Verifies the
stateparameter (CSRF protection). - Calls
provider.exchange_code(code)to obtainOAuth2UserInfo. - 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 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 |
|---|---|---|
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. |