|
| 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 | + ) |
0 commit comments