Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crudauth/oauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from . import providers as _providers # noqa: F401 (registers built-in providers)
from .factory import OAuthProviderFactory
from .provider import AbstractOAuthProvider
from .providers import GenericOIDCProvider
from .schemas import OAuthCredentials, OAuthState, OAuthUserInfo
from .service import OAuthAccountService

__all__ = [
"AbstractOAuthProvider",
"GenericOIDCProvider",
"OAuthProviderFactory",
"OAuthCredentials",
"OAuthUserInfo",
Expand Down
4 changes: 4 additions & 0 deletions crudauth/oauth/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@
GITHUB_USERINFO_ENDPOINT = "https://api.github.com/user"
GITHUB_EMAILS_ENDPOINT = "https://api.github.com/user/emails"
GITHUB_DEFAULT_SCOPES = ["read:user", "user:email"]

# Generic OIDC: discovery path + the minimal scopes for an id/userinfo lookup.
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
OIDC_DEFAULT_SCOPES = ["openid", "profile", "email"]
8 changes: 7 additions & 1 deletion crudauth/oauth/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
from ..factory import OAuthProviderFactory
from .github import GitHubOAuthProvider
from .google import GoogleOAuthProvider
from .oidc import GenericOIDCProvider

OAuthProviderFactory.register_provider(GOOGLE, GoogleOAuthProvider)
OAuthProviderFactory.register_provider(GITHUB, GitHubOAuthProvider)

__all__ = ["GoogleOAuthProvider", "GitHubOAuthProvider"]
# GenericOIDCProvider is intentionally NOT registered: the factory's
# create_provider builds from name + credentials alone, but an OIDC provider
# needs its endpoints resolved from an issuer first. Construct it with
# GenericOIDCProvider.from_discovery(...) instead.

__all__ = ["GoogleOAuthProvider", "GitHubOAuthProvider", "GenericOIDCProvider"]
154 changes: 154 additions & 0 deletions crudauth/oauth/providers/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Generic OpenID Connect provider, configured from an issuer via discovery.

Unlike Google/GitHub - which hardcode their endpoints - any spec-compliant OIDC
provider (Zitadel, Keycloak, Authentik, Auth0, Okta, Entra ID, ...) is described
entirely by its issuer's discovery document. ``from_discovery`` fetches that
document once and resolves the authorize/token/userinfo endpoints, so a caller
only supplies the issuer + credentials.

Why a classmethod and not just ``__init__``: discovery is an async HTTP call, but
the base port's ``get_authorization_url`` is synchronous and ``__init__`` cannot
``await``. Resolving the endpoints *before* construction keeps the whole sync
surface of the port intact - by the time any method runs, the endpoints are
concrete. Call it once at startup (where ``await`` is available) and reuse the
instance for the process.
"""

from __future__ import annotations

from typing import Any

from ...exceptions import BadRequestException
from ..constants import (
OAUTH_HTTP_TIMEOUT_SECONDS,
OIDC_DEFAULT_SCOPES,
OIDC_DISCOVERY_PATH,
)
from ..provider import AbstractOAuthProvider, _require_httpx
from ..schemas import OAuthUserInfo

__all__ = ["GenericOIDCProvider"]


class GenericOIDCProvider(AbstractOAuthProvider):
"""An OIDC provider whose endpoints come from the issuer's discovery document.

Construct it with [from_discovery][crudauth.oauth.providers.oidc.GenericOIDCProvider.from_discovery]
rather than calling ``__init__`` directly. The ``provider_name`` you pass is
what gets stored as the OAuth identity's provider, so it also determines the
``{provider_name}_id`` column the account is linked on (e.g. ``"zitadel"`` ->
``zitadel_id``). ``process_user_info`` reads the standard OIDC claims, which
every conformant provider returns from ``/userinfo``.
"""

def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str,
*,
scopes: list[str],
authorize_endpoint: str,
token_endpoint: str,
userinfo_endpoint: str,
provider_name: str,
issuer: str | None = None,
discovery_document: dict[str, Any] | None = None,
):
super().__init__(
client_id,
client_secret,
redirect_uri,
scopes=scopes,
authorize_endpoint=authorize_endpoint,
token_endpoint=token_endpoint,
userinfo_endpoint=userinfo_endpoint,
provider_name=provider_name,
)
# Kept for callers that want to validate id_tokens (jwks_uri) or build a
# logout URL (end_session_endpoint) later; the login flow itself uses only
# the three endpoints above.
self.issuer = issuer
self.discovery_document = discovery_document or {}

@classmethod
async def from_discovery(
cls,
issuer: str,
client_id: str,
client_secret: str,
redirect_uri: str,
*,
provider_name: str = "oidc",
scopes: list[str] | None = None,
transport: Any | None = None,
) -> GenericOIDCProvider:
"""Build a provider by fetching ``{issuer}/.well-known/openid-configuration``.

Args:
issuer: The OIDC issuer (instance base URL). A trailing slash is ignored.
provider_name: Identity/column name for the linked account (``zitadel`` ->
``zitadel_id``). Defaults to ``"oidc"``.
scopes: Override the default ``openid profile email``.
transport: Optional ``httpx`` transport, for tests (inject a
``MockTransport`` to avoid the network).

Raises:
ValueError: If the document is missing a required endpoint, or its
``issuer`` does not match the requested one (a spec requirement -
guards against a tampered/mismatched discovery document).
httpx.HTTPStatusError: If the discovery endpoint returns an error status.
"""
issuer = issuer.rstrip("/")
httpx = _require_httpx()
url = f"{issuer}{OIDC_DISCOVERY_PATH}"
async with httpx.AsyncClient(timeout=OAUTH_HTTP_TIMEOUT_SECONDS, transport=transport) as client:
resp = await client.get(url, headers={"Accept": "application/json"})
resp.raise_for_status()
doc = resp.json()

for key in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint"):
if not doc.get(key):
raise ValueError(f"OIDC discovery at {url} is missing '{key}'.")

doc_issuer = str(doc.get("issuer", "")).rstrip("/")
if doc_issuer and doc_issuer != issuer:
raise ValueError(
f"OIDC discovery issuer mismatch: requested {issuer!r}, document declares {doc_issuer!r}."
)

return cls(
client_id,
client_secret,
redirect_uri,
scopes=scopes or list(OIDC_DEFAULT_SCOPES),
authorize_endpoint=doc["authorization_endpoint"],
token_endpoint=doc["token_endpoint"],
userinfo_endpoint=doc["userinfo_endpoint"],
provider_name=provider_name,
issuer=doc_issuer or issuer,
discovery_document=doc,
)

async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo:
"""Normalize standard OIDC userinfo claims into ``OAuthUserInfo``.

Raises if ``sub`` is missing rather than coercing ``None`` into a real
``provider_user_id`` (mirrors the built-in providers). ``email_verified``
rides the claim honestly - auto-linking to an existing account needs it.
"""
sub = user_info.get("sub")
if sub is None:
raise BadRequestException(f"{self.provider_name} did not return a subject (sub).")
return OAuthUserInfo(
provider=self.provider_name,
provider_user_id=str(sub),
email=user_info.get("email"),
email_verified=bool(user_info.get("email_verified", False)),
name=user_info.get("name"),
given_name=user_info.get("given_name"),
family_name=user_info.get("family_name"),
username=user_info.get("preferred_username"),
picture=user_info.get("picture"),
raw_data=user_info,
)
117 changes: 117 additions & 0 deletions tests/oauth/test_oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""GenericOIDCProvider: discovery resolution, validation, and claim normalization."""

from __future__ import annotations

import httpx
import pytest
from fastapi import HTTPException

from crudauth.oauth import GenericOIDCProvider

# A Zitadel-shaped discovery document (same endpoint layout).
DISCOVERY = {
"issuer": "https://idp.example.com",
"authorization_endpoint": "https://idp.example.com/oauth/v2/authorize",
"token_endpoint": "https://idp.example.com/oauth/v2/token",
"userinfo_endpoint": "https://idp.example.com/oidc/v1/userinfo",
"jwks_uri": "https://idp.example.com/oauth/v2/keys",
}


def _transport(doc: dict, status: int = 200) -> httpx.MockTransport:
"""A MockTransport that serves ``doc`` at the discovery path (no network)."""

def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/.well-known/openid-configuration"
return httpx.Response(status, json=doc)

return httpx.MockTransport(handler)


def _provider(provider_name: str = "zitadel") -> GenericOIDCProvider:
"""A directly-constructed provider (bypasses discovery) for claim-mapping tests."""
return GenericOIDCProvider(
"id",
"sec",
"https://app/cb",
scopes=["openid"],
authorize_endpoint="https://idp.example.com/oauth/v2/authorize",
token_endpoint="https://idp.example.com/oauth/v2/token",
userinfo_endpoint="https://idp.example.com/oidc/v1/userinfo",
provider_name=provider_name,
)


# --- discovery resolves the three endpoints (trailing slash on issuer is ok) ---
async def test_from_discovery_resolves_endpoints() -> None:
prov = await GenericOIDCProvider.from_discovery(
"https://idp.example.com/", # trailing slash must be tolerated
"id",
"sec",
"https://app/cb",
provider_name="zitadel",
transport=_transport(DISCOVERY),
)
assert prov.authorize_endpoint == DISCOVERY["authorization_endpoint"]
assert prov.token_endpoint == DISCOVERY["token_endpoint"]
assert prov.userinfo_endpoint == DISCOVERY["userinfo_endpoint"]
assert prov.provider_name == "zitadel"
assert prov.issuer == "https://idp.example.com"
assert prov.discovery_document["jwks_uri"] == DISCOVERY["jwks_uri"]


# --- the base PKCE flow rides on top of the discovered endpoints --------------
async def test_from_discovery_authorize_url_uses_pkce_s256() -> None:
prov = await GenericOIDCProvider.from_discovery(
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(DISCOVERY)
)
auth = prov.get_authorization_url()
assert auth["url"].startswith("https://idp.example.com/oauth/v2/authorize?")
assert "code_challenge_method=S256" in auth["url"]
assert "code_verifier" in auth # stored server-side to verify the callback


# --- a discovery document missing a required endpoint is rejected -------------
async def test_from_discovery_missing_endpoint_raises() -> None:
doc = {k: v for k, v in DISCOVERY.items() if k != "token_endpoint"}
with pytest.raises(ValueError, match="token_endpoint"):
await GenericOIDCProvider.from_discovery(
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(doc)
)


# --- a mismatched issuer in the document is rejected (spec requirement) --------
async def test_from_discovery_issuer_mismatch_raises() -> None:
doc = {**DISCOVERY, "issuer": "https://evil.example.com"}
with pytest.raises(ValueError, match="issuer"):
await GenericOIDCProvider.from_discovery(
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(doc)
)


# --- standard OIDC claims map straight through; provider_name is preserved ----
async def test_process_user_info_maps_standard_claims() -> None:
info = await _provider("zitadel").process_user_info(
{
"sub": "z-1",
"email": "u@x.com",
"email_verified": True,
"preferred_username": "user1",
"name": "User One",
"given_name": "User",
"family_name": "One",
"picture": "https://idp/p.png",
}
)
assert info.provider == "zitadel" # drives the zitadel_id linking column
assert info.provider_user_id == "z-1"
assert info.username == "user1"
assert info.email_verified is True
assert info.given_name == "User"


# --- a missing sub raises rather than coercing None into an id ----------------
async def test_process_user_info_missing_sub_raises() -> None:
with pytest.raises(HTTPException) as exc:
await _provider().process_user_info({"email": "u@x.com"}) # no "sub"
assert exc.value.status_code == 400