from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import httpx
from pydantic import BaseModel, ConfigDict
if TYPE_CHECKING:
from collections.abc import Sequence
TokenAuthMethod = Literal["client_secret_post", "client_secret_basic"]
_DEFAULT_AUTH_METHOD: TokenAuthMethod = "client_secret_post"
_DEFAULT_TIMEOUT = 10.0
GOOGLE_ISSUER = "https://accounts.google.com"
_MS_WILDCARD_TENANTS = frozenset({"common", "organizations", "consumers"})
[docs]
class OAuthConfigError(ValueError):
pass
[docs]
class ClientCredentials(BaseModel):
model_config = ConfigDict(frozen=True)
client_id: str
client_secret_env: str | None = None
redirect_path: str = ""
[docs]
class ProviderConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str | None = None
jwks_uri: str | None = None
issuer: str | None = None
token_endpoint_auth_method: TokenAuthMethod = _DEFAULT_AUTH_METHOD
default_scopes: tuple[str, ...] = ()
verify_email: bool = False
tenant: str | None = None
tenant_base: str | None = None
def for_tenant(self, tenant: str) -> ProviderConfig:
if self.tenant is None or self.tenant_base is None:
msg = "provider endpoints are not tenant-scoped"
raise OAuthConfigError(msg)
base = f"{self.tenant_base.rstrip('/')}/{tenant}"
issuer = None if tenant in _MS_WILDCARD_TENANTS else f"{base}/v2.0"
return self.model_copy(
update={
"authorization_endpoint": f"{base}/oauth2/v2.0/authorize",
"token_endpoint": f"{base}/oauth2/v2.0/token",
"jwks_uri": f"{base}/discovery/v2.0/keys",
"issuer": issuer,
"tenant": tenant,
},
)
@property
def admin_consent_endpoint(self) -> str:
if self.tenant is None or self.tenant_base is None:
msg = "provider endpoints are not tenant-scoped"
raise OAuthConfigError(msg)
base = f"{self.tenant_base.rstrip('/')}/{self.tenant}"
return f"{base}/adminconsent"
async def discover(
issuer: str,
*,
client: httpx.AsyncClient | None = None,
default_scopes: Sequence[str] = ("openid", "email", "profile"),
verify_email: bool = False,
timeout: float = _DEFAULT_TIMEOUT, # noqa: ASYNC109 -- forwarded to httpx
) -> ProviderConfig:
url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
owns_client = client is None
client = client or httpx.AsyncClient(timeout=timeout)
try:
response = await client.get(url)
response.raise_for_status()
document = response.json()
except httpx.HTTPError as exc:
msg = f"OIDC discovery failed for {issuer!r}: {exc}"
raise OAuthConfigError(msg) from exc
except ValueError as exc:
msg = f"OIDC discovery document for {issuer!r} is not valid JSON"
raise OAuthConfigError(msg) from exc
finally:
if owns_client:
await client.aclose()
missing = [
key
for key in ("authorization_endpoint", "token_endpoint")
if not document.get(key)
]
if missing:
msg = (
f"OIDC discovery document for {issuer!r} is missing "
f"required field(s): {missing}"
)
raise OAuthConfigError(msg)
document_issuer = document.get("issuer")
if document_issuer is not None and document_issuer.rstrip(
"/"
) != issuer.rstrip("/"):
msg = (
f"OIDC discovery document issuer {document_issuer!r} does "
f"not match the requested issuer {issuer!r}"
)
raise OAuthConfigError(msg)
return ProviderConfig(
authorization_endpoint=document["authorization_endpoint"],
token_endpoint=document["token_endpoint"],
userinfo_endpoint=document.get("userinfo_endpoint"),
jwks_uri=document.get("jwks_uri"),
issuer=document_issuer or issuer,
default_scopes=tuple(default_scopes),
verify_email=verify_email,
)
def google(
*,
default_scopes: Sequence[str] = ("openid", "email", "profile"),
verify_email: bool = False,
) -> ProviderConfig:
return ProviderConfig(
authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
token_endpoint="https://oauth2.googleapis.com/token", # noqa: S106
userinfo_endpoint="https://openidconnect.googleapis.com/v1/userinfo",
jwks_uri="https://www.googleapis.com/oauth2/v3/certs",
issuer=GOOGLE_ISSUER,
default_scopes=tuple(default_scopes),
verify_email=verify_email,
)
def microsoft(
tenant: str = "common",
*,
default_scopes: Sequence[str] = ("openid", "email", "profile"),
verify_email: bool = False,
) -> ProviderConfig:
base = f"https://login.microsoftonline.com/{tenant}"
issuer = None if tenant in _MS_WILDCARD_TENANTS else f"{base}/v2.0"
return ProviderConfig(
authorization_endpoint=f"{base}/oauth2/v2.0/authorize",
token_endpoint=f"{base}/oauth2/v2.0/token",
userinfo_endpoint="https://graph.microsoft.com/oidc/userinfo",
jwks_uri=f"{base}/discovery/v2.0/keys",
issuer=issuer,
default_scopes=tuple(default_scopes),
verify_email=verify_email,
tenant=tenant,
tenant_base="https://login.microsoftonline.com",
)