Skip to content

Commit 30eca67

Browse files
feat: add discovery-based GenericOIDCProvider
A single provider for any spec-compliant OpenID Connect IdP (Zitadel, Keycloak, Authentik, Auth0, Okta, Entra ID, ...). from_discovery() fetches {issuer}/.well-known/openid-configuration, resolves the authorize/token/ userinfo endpoints, and validates the declared issuer; process_user_info maps the standard OIDC claims. Construction is via an async classmethod because discovery is a network call and the port's sync surface (get_authorization_url) cannot await; build it once at startup. Deliberately not registered with OAuthProviderFactory: create_provider builds from name + credentials alone and cannot carry an issuer, so registering it would only produce a confusing TypeError. Callers construct it directly instead. Adds OIDC_DISCOVERY_PATH / OIDC_DEFAULT_SCOPES constants and test_oidc.py (discovery resolution, PKCE URL, issuer/endpoint validation, claim mapping). Signed-off-by: Carlos Andrés Planchón Prestes <carlosandresplanchonprestes@gmail.com>
1 parent 21cf51a commit 30eca67

5 files changed

Lines changed: 284 additions & 1 deletion

File tree

crudauth/oauth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from . import providers as _providers # noqa: F401 (registers built-in providers)
1010
from .factory import OAuthProviderFactory
1111
from .provider import AbstractOAuthProvider
12+
from .providers import GenericOIDCProvider
1213
from .schemas import OAuthCredentials, OAuthState, OAuthUserInfo
1314
from .service import OAuthAccountService
1415

1516
__all__ = [
1617
"AbstractOAuthProvider",
18+
"GenericOIDCProvider",
1719
"OAuthProviderFactory",
1820
"OAuthCredentials",
1921
"OAuthUserInfo",

crudauth/oauth/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,7 @@
4242
GITHUB_USERINFO_ENDPOINT = "https://api.github.com/user"
4343
GITHUB_EMAILS_ENDPOINT = "https://api.github.com/user/emails"
4444
GITHUB_DEFAULT_SCOPES = ["read:user", "user:email"]
45+
46+
# Generic OIDC: discovery path + the minimal scopes for an id/userinfo lookup.
47+
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
48+
OIDC_DEFAULT_SCOPES = ["openid", "profile", "email"]

crudauth/oauth/providers/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@
66
from ..factory import OAuthProviderFactory
77
from .github import GitHubOAuthProvider
88
from .google import GoogleOAuthProvider
9+
from .oidc import GenericOIDCProvider
910

1011
OAuthProviderFactory.register_provider(GOOGLE, GoogleOAuthProvider)
1112
OAuthProviderFactory.register_provider(GITHUB, GitHubOAuthProvider)
1213

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

crudauth/oauth/providers/oidc.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Generic OpenID Connect provider, configured from an issuer via discovery.
2+
3+
Unlike Google/GitHub - which hardcode their endpoints - any spec-compliant OIDC
4+
provider (Zitadel, Keycloak, Authentik, Auth0, Okta, Entra ID, ...) is described
5+
entirely by its issuer's discovery document. ``from_discovery`` fetches that
6+
document once and resolves the authorize/token/userinfo endpoints, so a caller
7+
only supplies the issuer + credentials.
8+
9+
Why a classmethod and not just ``__init__``: discovery is an async HTTP call, but
10+
the base port's ``get_authorization_url`` is synchronous and ``__init__`` cannot
11+
``await``. Resolving the endpoints *before* construction keeps the whole sync
12+
surface of the port intact - by the time any method runs, the endpoints are
13+
concrete. Call it once at startup (where ``await`` is available) and reuse the
14+
instance for the process.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any
20+
21+
from ...exceptions import BadRequestException
22+
from ..constants import (
23+
OAUTH_HTTP_TIMEOUT_SECONDS,
24+
OIDC_DEFAULT_SCOPES,
25+
OIDC_DISCOVERY_PATH,
26+
)
27+
from ..provider import AbstractOAuthProvider, _require_httpx
28+
from ..schemas import OAuthUserInfo
29+
30+
__all__ = ["GenericOIDCProvider"]
31+
32+
33+
class GenericOIDCProvider(AbstractOAuthProvider):
34+
"""An OIDC provider whose endpoints come from the issuer's discovery document.
35+
36+
Construct it with [from_discovery][crudauth.oauth.providers.oidc.GenericOIDCProvider.from_discovery]
37+
rather than calling ``__init__`` directly. The ``provider_name`` you pass is
38+
what gets stored as the OAuth identity's provider, so it also determines the
39+
``{provider_name}_id`` column the account is linked on (e.g. ``"zitadel"`` ->
40+
``zitadel_id``). ``process_user_info`` reads the standard OIDC claims, which
41+
every conformant provider returns from ``/userinfo``.
42+
"""
43+
44+
def __init__(
45+
self,
46+
client_id: str,
47+
client_secret: str,
48+
redirect_uri: str,
49+
*,
50+
scopes: list[str],
51+
authorize_endpoint: str,
52+
token_endpoint: str,
53+
userinfo_endpoint: str,
54+
provider_name: str,
55+
issuer: str | None = None,
56+
discovery_document: dict[str, Any] | None = None,
57+
):
58+
super().__init__(
59+
client_id,
60+
client_secret,
61+
redirect_uri,
62+
scopes=scopes,
63+
authorize_endpoint=authorize_endpoint,
64+
token_endpoint=token_endpoint,
65+
userinfo_endpoint=userinfo_endpoint,
66+
provider_name=provider_name,
67+
)
68+
# Kept for callers that want to validate id_tokens (jwks_uri) or build a
69+
# logout URL (end_session_endpoint) later; the login flow itself uses only
70+
# the three endpoints above.
71+
self.issuer = issuer
72+
self.discovery_document = discovery_document or {}
73+
74+
@classmethod
75+
async def from_discovery(
76+
cls,
77+
issuer: str,
78+
client_id: str,
79+
client_secret: str,
80+
redirect_uri: str,
81+
*,
82+
provider_name: str = "oidc",
83+
scopes: list[str] | None = None,
84+
transport: Any | None = None,
85+
) -> GenericOIDCProvider:
86+
"""Build a provider by fetching ``{issuer}/.well-known/openid-configuration``.
87+
88+
Args:
89+
issuer: The OIDC issuer (instance base URL). A trailing slash is ignored.
90+
provider_name: Identity/column name for the linked account (``zitadel`` ->
91+
``zitadel_id``). Defaults to ``"oidc"``.
92+
scopes: Override the default ``openid profile email``.
93+
transport: Optional ``httpx`` transport, for tests (inject a
94+
``MockTransport`` to avoid the network).
95+
96+
Raises:
97+
ValueError: If the document is missing a required endpoint, or its
98+
``issuer`` does not match the requested one (a spec requirement -
99+
guards against a tampered/mismatched discovery document).
100+
httpx.HTTPStatusError: If the discovery endpoint returns an error status.
101+
"""
102+
issuer = issuer.rstrip("/")
103+
httpx = _require_httpx()
104+
url = f"{issuer}{OIDC_DISCOVERY_PATH}"
105+
async with httpx.AsyncClient(timeout=OAUTH_HTTP_TIMEOUT_SECONDS, transport=transport) as client:
106+
resp = await client.get(url, headers={"Accept": "application/json"})
107+
resp.raise_for_status()
108+
doc = resp.json()
109+
110+
for key in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint"):
111+
if not doc.get(key):
112+
raise ValueError(f"OIDC discovery at {url} is missing '{key}'.")
113+
114+
doc_issuer = str(doc.get("issuer", "")).rstrip("/")
115+
if doc_issuer and doc_issuer != issuer:
116+
raise ValueError(
117+
f"OIDC discovery issuer mismatch: requested {issuer!r}, document declares {doc_issuer!r}."
118+
)
119+
120+
return cls(
121+
client_id,
122+
client_secret,
123+
redirect_uri,
124+
scopes=scopes or list(OIDC_DEFAULT_SCOPES),
125+
authorize_endpoint=doc["authorization_endpoint"],
126+
token_endpoint=doc["token_endpoint"],
127+
userinfo_endpoint=doc["userinfo_endpoint"],
128+
provider_name=provider_name,
129+
issuer=doc_issuer or issuer,
130+
discovery_document=doc,
131+
)
132+
133+
async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo:
134+
"""Normalize standard OIDC userinfo claims into ``OAuthUserInfo``.
135+
136+
Raises if ``sub`` is missing rather than coercing ``None`` into a real
137+
``provider_user_id`` (mirrors the built-in providers). ``email_verified``
138+
rides the claim honestly - auto-linking to an existing account needs it.
139+
"""
140+
sub = user_info.get("sub")
141+
if sub is None:
142+
raise BadRequestException(f"{self.provider_name} did not return a subject (sub).")
143+
return OAuthUserInfo(
144+
provider=self.provider_name,
145+
provider_user_id=str(sub),
146+
email=user_info.get("email"),
147+
email_verified=bool(user_info.get("email_verified", False)),
148+
name=user_info.get("name"),
149+
given_name=user_info.get("given_name"),
150+
family_name=user_info.get("family_name"),
151+
username=user_info.get("preferred_username"),
152+
picture=user_info.get("picture"),
153+
raw_data=user_info,
154+
)

tests/oauth/test_oidc.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""GenericOIDCProvider: discovery resolution, validation, and claim normalization."""
2+
3+
from __future__ import annotations
4+
5+
import httpx
6+
import pytest
7+
from fastapi import HTTPException
8+
9+
from crudauth.oauth import GenericOIDCProvider
10+
11+
# A Zitadel-shaped discovery document (same endpoint layout).
12+
DISCOVERY = {
13+
"issuer": "https://idp.example.com",
14+
"authorization_endpoint": "https://idp.example.com/oauth/v2/authorize",
15+
"token_endpoint": "https://idp.example.com/oauth/v2/token",
16+
"userinfo_endpoint": "https://idp.example.com/oidc/v1/userinfo",
17+
"jwks_uri": "https://idp.example.com/oauth/v2/keys",
18+
}
19+
20+
21+
def _transport(doc: dict, status: int = 200) -> httpx.MockTransport:
22+
"""A MockTransport that serves ``doc`` at the discovery path (no network)."""
23+
24+
def handler(request: httpx.Request) -> httpx.Response:
25+
assert request.url.path == "/.well-known/openid-configuration"
26+
return httpx.Response(status, json=doc)
27+
28+
return httpx.MockTransport(handler)
29+
30+
31+
def _provider(provider_name: str = "zitadel") -> GenericOIDCProvider:
32+
"""A directly-constructed provider (bypasses discovery) for claim-mapping tests."""
33+
return GenericOIDCProvider(
34+
"id",
35+
"sec",
36+
"https://app/cb",
37+
scopes=["openid"],
38+
authorize_endpoint="https://idp.example.com/oauth/v2/authorize",
39+
token_endpoint="https://idp.example.com/oauth/v2/token",
40+
userinfo_endpoint="https://idp.example.com/oidc/v1/userinfo",
41+
provider_name=provider_name,
42+
)
43+
44+
45+
# --- discovery resolves the three endpoints (trailing slash on issuer is ok) ---
46+
async def test_from_discovery_resolves_endpoints() -> None:
47+
prov = await GenericOIDCProvider.from_discovery(
48+
"https://idp.example.com/", # trailing slash must be tolerated
49+
"id",
50+
"sec",
51+
"https://app/cb",
52+
provider_name="zitadel",
53+
transport=_transport(DISCOVERY),
54+
)
55+
assert prov.authorize_endpoint == DISCOVERY["authorization_endpoint"]
56+
assert prov.token_endpoint == DISCOVERY["token_endpoint"]
57+
assert prov.userinfo_endpoint == DISCOVERY["userinfo_endpoint"]
58+
assert prov.provider_name == "zitadel"
59+
assert prov.issuer == "https://idp.example.com"
60+
assert prov.discovery_document["jwks_uri"] == DISCOVERY["jwks_uri"]
61+
62+
63+
# --- the base PKCE flow rides on top of the discovered endpoints --------------
64+
async def test_from_discovery_authorize_url_uses_pkce_s256() -> None:
65+
prov = await GenericOIDCProvider.from_discovery(
66+
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(DISCOVERY)
67+
)
68+
auth = prov.get_authorization_url()
69+
assert auth["url"].startswith("https://idp.example.com/oauth/v2/authorize?")
70+
assert "code_challenge_method=S256" in auth["url"]
71+
assert "code_verifier" in auth # stored server-side to verify the callback
72+
73+
74+
# --- a discovery document missing a required endpoint is rejected -------------
75+
async def test_from_discovery_missing_endpoint_raises() -> None:
76+
doc = {k: v for k, v in DISCOVERY.items() if k != "token_endpoint"}
77+
with pytest.raises(ValueError, match="token_endpoint"):
78+
await GenericOIDCProvider.from_discovery(
79+
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(doc)
80+
)
81+
82+
83+
# --- a mismatched issuer in the document is rejected (spec requirement) --------
84+
async def test_from_discovery_issuer_mismatch_raises() -> None:
85+
doc = {**DISCOVERY, "issuer": "https://evil.example.com"}
86+
with pytest.raises(ValueError, match="issuer"):
87+
await GenericOIDCProvider.from_discovery(
88+
"https://idp.example.com", "id", "sec", "https://app/cb", transport=_transport(doc)
89+
)
90+
91+
92+
# --- standard OIDC claims map straight through; provider_name is preserved ----
93+
async def test_process_user_info_maps_standard_claims() -> None:
94+
info = await _provider("zitadel").process_user_info(
95+
{
96+
"sub": "z-1",
97+
"email": "u@x.com",
98+
"email_verified": True,
99+
"preferred_username": "user1",
100+
"name": "User One",
101+
"given_name": "User",
102+
"family_name": "One",
103+
"picture": "https://idp/p.png",
104+
}
105+
)
106+
assert info.provider == "zitadel" # drives the zitadel_id linking column
107+
assert info.provider_user_id == "z-1"
108+
assert info.username == "user1"
109+
assert info.email_verified is True
110+
assert info.given_name == "User"
111+
112+
113+
# --- a missing sub raises rather than coercing None into an id ----------------
114+
async def test_process_user_info_missing_sub_raises() -> None:
115+
with pytest.raises(HTTPException) as exc:
116+
await _provider().process_user_info({"email": "u@x.com"}) # no "sub"
117+
assert exc.value.status_code == 400

0 commit comments

Comments
 (0)