eager-auth 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eager_auth/__init__.py ADDED
@@ -0,0 +1,115 @@
1
+ """eager-auth — standard OIDC relying party for Eager FastAPI services.
2
+
3
+ The public surface of the verification core. The authorization-code flow, the session
4
+ cookie and the FastAPI glue build on top of this and do not change it.
5
+
6
+ See CONTRACT.md at the repository root.
7
+ """
8
+
9
+ from eager_auth.claims import claim_at, extract_org, extract_roles
10
+ from eager_auth.config import (
11
+ ALLOWED_ALGORITHMS,
12
+ DEFAULT_ORG_CLAIM,
13
+ DEFAULT_ROLES_CLAIM,
14
+ DEFAULT_SESSION_TTL_SECONDS,
15
+ AuthConfig,
16
+ )
17
+ from eager_auth.context import AuthContext
18
+ from eager_auth.discovery import Discovery, fetch_discovery
19
+ from eager_auth.errors import (
20
+ AuthError,
21
+ BadSignatureError,
22
+ ConfigError,
23
+ DiscoveryError,
24
+ ExpiredTokenError,
25
+ InvalidSessionError,
26
+ InvalidTokenError,
27
+ MalformedTokenError,
28
+ NonceMismatchError,
29
+ RefreshRejectedError,
30
+ StateMismatchError,
31
+ TokenExchangeError,
32
+ UnknownKeyError,
33
+ UnsupportedAlgorithmError,
34
+ WrongAudienceError,
35
+ WrongIssuerError,
36
+ )
37
+ from eager_auth.flow import (
38
+ CompletedLogin,
39
+ PendingLogin,
40
+ code_challenge_for,
41
+ complete_login,
42
+ logout_url,
43
+ refresh_session,
44
+ start_login,
45
+ )
46
+ from eager_auth.keys import HttpJwksSource, JwksSource, KeyResolver
47
+ from eager_auth.pending import PendingState, safe_next, seal_pending, unseal_pending
48
+ from eager_auth.presets import KEYCLOAK_REALM_ROLES, ZITADEL, keycloak_client_roles
49
+ from eager_auth.principal import Principal
50
+ from eager_auth.session import (
51
+ CookieAttributes,
52
+ SessionPayload,
53
+ decode_session,
54
+ encode_session,
55
+ needs_refresh,
56
+ pending_cookie,
57
+ session_cookie,
58
+ )
59
+ from eager_auth.tokens import verify_id_token
60
+
61
+ __all__ = [
62
+ "ALLOWED_ALGORITHMS",
63
+ "AuthConfig",
64
+ "AuthContext",
65
+ "AuthError",
66
+ "BadSignatureError",
67
+ "CompletedLogin",
68
+ "ConfigError",
69
+ "CookieAttributes",
70
+ "DEFAULT_ORG_CLAIM",
71
+ "DEFAULT_ROLES_CLAIM",
72
+ "DEFAULT_SESSION_TTL_SECONDS",
73
+ "Discovery",
74
+ "DiscoveryError",
75
+ "ExpiredTokenError",
76
+ "HttpJwksSource",
77
+ "InvalidSessionError",
78
+ "InvalidTokenError",
79
+ "JwksSource",
80
+ "KEYCLOAK_REALM_ROLES",
81
+ "KeyResolver",
82
+ "MalformedTokenError",
83
+ "NonceMismatchError",
84
+ "PendingLogin",
85
+ "PendingState",
86
+ "Principal",
87
+ "RefreshRejectedError",
88
+ "SessionPayload",
89
+ "StateMismatchError",
90
+ "TokenExchangeError",
91
+ "UnknownKeyError",
92
+ "UnsupportedAlgorithmError",
93
+ "WrongAudienceError",
94
+ "WrongIssuerError",
95
+ "ZITADEL",
96
+ "claim_at",
97
+ "code_challenge_for",
98
+ "complete_login",
99
+ "decode_session",
100
+ "encode_session",
101
+ "extract_org",
102
+ "extract_roles",
103
+ "fetch_discovery",
104
+ "keycloak_client_roles",
105
+ "logout_url",
106
+ "needs_refresh",
107
+ "pending_cookie",
108
+ "refresh_session",
109
+ "safe_next",
110
+ "seal_pending",
111
+ "session_cookie",
112
+ "start_login",
113
+ "unseal_pending",
114
+ "verify_id_token",
115
+ ]
eager_auth/claims.py ADDED
@@ -0,0 +1,78 @@
1
+ """Reading claims out of a token — see CONTRACT.md section 3.
2
+
3
+ Brokers disagree about where authorization data lives, and the disagreement is structural,
4
+ not cosmetic:
5
+
6
+ * **Zitadel** puts a flat claim holding an object map keyed by role name.
7
+ * **Keycloak** nests roles at `realm_access.roles` (realm roles) or
8
+ `resource_access.<client>.roles` (client roles), and its Organizations feature emits an
9
+ `organization` claim keyed by org alias.
10
+
11
+ So claim paths are **dotted** and the extractors accept every shape we have actually seen.
12
+ This is the one place the module bends to a provider — by configuration, never by branching
13
+ on which broker is in use.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+
21
+ def claim_at(claims: dict[str, Any], path: str) -> Any: # noqa: ANN401
22
+ """Read a claim by dotted path.
23
+
24
+ A flat name like `email` works as before. `realm_access.roles` walks into the nested
25
+ object. Any missing hop, or a hop that is not an object, yields None rather than an
26
+ error: a token that does not carry the claim is a legitimate token.
27
+
28
+ The full path is tried as a literal key first, because Zitadel's claim names contain
29
+ dots (`urn:zitadel:iam:org:project:roles` does not, but others do) and a literal match
30
+ must win over a walk.
31
+ """
32
+ if path in claims:
33
+ return claims[path]
34
+
35
+ current: Any = claims
36
+ for hop in path.split("."):
37
+ if not isinstance(current, dict) or hop not in current:
38
+ return None
39
+ current = current[hop]
40
+ return current
41
+
42
+
43
+ def extract_roles(claim_value: Any) -> tuple[str, ...]: # noqa: ANN401
44
+ """Normalise a roles claim into a sorted, de-duplicated tuple.
45
+
46
+ Accepted shapes: a list of strings (Keycloak), and an object map keyed by role name
47
+ (Zitadel). Anything else yields an empty tuple rather than an error — a user with no
48
+ roles is a legitimate state, and it is the service's job to decide what to do about it.
49
+ """
50
+ if isinstance(claim_value, dict):
51
+ names = [str(name) for name in claim_value]
52
+ elif isinstance(claim_value, (list, tuple, set)):
53
+ names = [str(name) for name in claim_value if isinstance(name, str)]
54
+ else:
55
+ return ()
56
+ return tuple(sorted({name for name in names if name}))
57
+
58
+
59
+ def extract_org(claim_value: Any) -> str | None: # noqa: ANN401
60
+ """Normalise an org claim into a single identifier.
61
+
62
+ Accepted shapes: a string (Zitadel's primary domain), a one-element list, and an object
63
+ map keyed by org alias (Keycloak Organizations). When a map or list carries more than
64
+ one entry the first sorted key wins, deterministically — a principal belonging to
65
+ several orgs is out of scope for this contract, and picking at random would be worse
66
+ than picking predictably.
67
+ """
68
+ if isinstance(claim_value, str):
69
+ return claim_value or None
70
+ if isinstance(claim_value, dict):
71
+ names = sorted(str(name) for name in claim_value if str(name))
72
+ return names[0] if names else None
73
+ if isinstance(claim_value, (list, tuple)):
74
+ names = sorted(str(name) for name in claim_value if isinstance(name, str) and name)
75
+ return names[0] if names else None
76
+ if isinstance(claim_value, int) and not isinstance(claim_value, bool):
77
+ return str(claim_value)
78
+ return None
eager_auth/config.py ADDED
@@ -0,0 +1,159 @@
1
+ """Configuration — see CONTRACT.md section 2.
2
+
3
+ The broker's identity lives in exactly one place: `issuer`. Everything else about the
4
+ provider is discovered from `{issuer}/.well-known/openid-configuration`, which is what
5
+ makes swapping brokers a config change instead of a rewrite.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from urllib.parse import urlsplit
12
+
13
+ from eager_auth.errors import ConfigError
14
+
15
+ #: Asymmetric only. See CONTRACT.md section 4 step 1.
16
+ ALLOWED_ALGORITHMS: frozenset[str] = frozenset(
17
+ {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}
18
+ )
19
+
20
+ DEFAULT_ROLES_CLAIM = "urn:zitadel:iam:org:project:roles"
21
+ DEFAULT_ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:primary_domain"
22
+ DEFAULT_SESSION_TTL_SECONDS = 28_800 # 8h — one value for both adapters, on purpose.
23
+
24
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
25
+
26
+
27
+ def _is_loopback(url: str) -> bool:
28
+ """Whether this is a plain-http loopback URL, the one place http is acceptable.
29
+
30
+ The host is parsed and compared exactly. A prefix test would accept
31
+ `http://localhost.evil.test`, which is an attacker-controlled domain that merely starts
32
+ with the right letters — caught by a test rather than in review.
33
+ """
34
+ if not url.startswith("http://"):
35
+ return False
36
+ try:
37
+ host = urlsplit(url).hostname
38
+ except ValueError:
39
+ return False
40
+ return host in _LOOPBACK_HOSTS
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AuthConfig:
45
+ """Everything a service must state to authenticate its users.
46
+
47
+ Args:
48
+ issuer: Broker base URL, https, no trailing slash.
49
+ client_id: This service's OIDC client id.
50
+ client_secret: This service's OIDC client secret.
51
+ audience: Expected `aud`. Defaults to `client_id`.
52
+ cookie_name: Session cookie name; shared across the estate.
53
+ session_ttl_seconds: Session lifetime.
54
+ route_prefix: Where the login/callback/logout/me routes are mounted.
55
+ roles_claim: Claim carrying roles. Configurable to keep the broker swappable.
56
+ org_claim: Claim carrying the org/tenant identifier.
57
+ redirect_uri: Where the broker sends the user back. Required for the login flow.
58
+ post_logout_redirect_uri: Absolute URL the broker returns to after a global logout.
59
+ Must be one the broker has registered. Taken from config rather than from the
60
+ request host, which behind a load balancer is not the public one.
61
+ secure_cookies: Force the cookie `Secure` flag. Leave None to derive it from
62
+ `redirect_uri` — off for http://localhost, on everywhere else.
63
+ session_key: 32 bytes, base64url, encrypting the session cookie. Required for the flow.
64
+ scopes: Requested scopes.
65
+ pending_cookie_name: Cookie holding the in-flight state/nonce/verifier.
66
+ pending_ttl_seconds: How long a login attempt may take.
67
+ jwks_max_age_seconds: How long a fetched key set stays usable.
68
+ clock_skew_seconds: Tolerance applied to `exp`, `nbf` and `iat`.
69
+
70
+ Raises:
71
+ ConfigError: If any value is unusable.
72
+
73
+ """
74
+
75
+ issuer: str
76
+ client_id: str
77
+ client_secret: str
78
+ audience: str | None = None
79
+ cookie_name: str = "access_token"
80
+ session_ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS
81
+ route_prefix: str = "/api/auth"
82
+ roles_claim: str = DEFAULT_ROLES_CLAIM
83
+ org_claim: str = DEFAULT_ORG_CLAIM
84
+ redirect_uri: str | None = None
85
+ post_logout_redirect_uri: str | None = None
86
+ session_key: str | None = None
87
+ # None means "derive from redirect_uri": http://localhost is development, where the
88
+ # browser will not store a Secure cookie, so hardcoding True made local login
89
+ # impossible while the validator below deliberately allowed a localhost redirect.
90
+ secure_cookies: bool | None = None
91
+ scopes: tuple[str, ...] = ("openid", "email", "profile")
92
+ pending_cookie_name: str = "auth_pending"
93
+ pending_ttl_seconds: int = 600
94
+ jwks_max_age_seconds: int = 3_600
95
+ clock_skew_seconds: int = 60
96
+ allowed_algorithms: frozenset[str] = field(default=ALLOWED_ALGORITHMS)
97
+
98
+ def __post_init__(self) -> None:
99
+ """Validate eagerly, so a misconfigured service fails at boot, not at first login."""
100
+ if not self.issuer:
101
+ raise ConfigError("issuer is required")
102
+ if not self.issuer.startswith("https://") and not _is_loopback(self.issuer):
103
+ # http is tolerated only on loopback, which is what a local broker and local development
104
+ # need. RFC 8252 makes the same carve-out for exactly this reason. Everything else must
105
+ # be https: an issuer reached over plain http can be substituted in transit, and every
106
+ # signature check downstream then verifies against the attacker's keys.
107
+ raise ConfigError(f"issuer must be https, got {self.issuer!r}")
108
+ if self.issuer.endswith("/"):
109
+ raise ConfigError(f"issuer must not end with a slash, got {self.issuer!r}")
110
+ if not self.client_id:
111
+ raise ConfigError("client_id is required")
112
+ if not self.client_secret:
113
+ raise ConfigError("client_secret is required")
114
+ if self.redirect_uri is not None and not self.redirect_uri.startswith("https://"):
115
+ if not _is_loopback(self.redirect_uri):
116
+ raise ConfigError(f"redirect_uri must be https, got {self.redirect_uri!r}")
117
+ if not self.scopes or "openid" not in self.scopes:
118
+ raise ConfigError("scopes must include 'openid'")
119
+ if self.pending_ttl_seconds <= 0:
120
+ raise ConfigError("pending_ttl_seconds must be positive")
121
+ if self.pending_cookie_name == self.cookie_name:
122
+ raise ConfigError("pending_cookie_name must differ from cookie_name")
123
+ if self.session_ttl_seconds <= 0:
124
+ raise ConfigError("session_ttl_seconds must be positive")
125
+ if self.jwks_max_age_seconds <= 0:
126
+ raise ConfigError("jwks_max_age_seconds must be positive")
127
+ if self.clock_skew_seconds < 0:
128
+ raise ConfigError("clock_skew_seconds must not be negative")
129
+ if not self.allowed_algorithms:
130
+ raise ConfigError("allowed_algorithms must not be empty")
131
+ symmetric = {a for a in self.allowed_algorithms if not a.startswith(("RS", "ES", "PS"))}
132
+ if symmetric:
133
+ raise ConfigError(f"symmetric algorithms are not allowed: {sorted(symmetric)}")
134
+
135
+ @property
136
+ def cookies_secure(self) -> bool:
137
+ """Whether cookies carry `Secure`.
138
+
139
+ Derived unless set explicitly: a Secure cookie is never stored over plain http, so a
140
+ localhost redirect_uri and a Secure cookie together mean login silently fails.
141
+ """
142
+ if self.secure_cookies is not None:
143
+ return self.secure_cookies
144
+ return not _is_loopback(self.redirect_uri or "")
145
+
146
+ @property
147
+ def expected_audience(self) -> str:
148
+ """The `aud` a token must carry — `audience` when set, otherwise `client_id`."""
149
+ return self.audience or self.client_id
150
+
151
+ @property
152
+ def scope_string(self) -> str:
153
+ """Scopes as the authorization request wants them."""
154
+ return " ".join(self.scopes)
155
+
156
+ @property
157
+ def discovery_url(self) -> str:
158
+ """Where the provider's metadata lives."""
159
+ return f"{self.issuer}/.well-known/openid-configuration"
eager_auth/context.py ADDED
@@ -0,0 +1,54 @@
1
+ """Everything a request handler needs, assembled once — see CONTRACT.md section 11.
2
+
3
+ A service builds one of these at startup and hands it to the router. Discovery is fetched
4
+ lazily and cached, so importing the module never performs I/O and a broker that is briefly
5
+ unreachable at boot does not stop the service from starting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+ from eager_auth.config import AuthConfig
13
+ from eager_auth.discovery import Discovery, fetch_discovery
14
+ from eager_auth.keys import HttpJwksSource, KeyResolver
15
+
16
+
17
+ class AuthContext:
18
+ """Config, discovery and the key resolver, resolved on first use."""
19
+
20
+ def __init__(
21
+ self,
22
+ config: AuthConfig,
23
+ *,
24
+ client: httpx.Client | None = None,
25
+ keys: KeyResolver | None = None,
26
+ discovery: Discovery | None = None,
27
+ ) -> None:
28
+ """Assemble a context.
29
+
30
+ Args:
31
+ config: This service's auth configuration.
32
+ client: HTTP client for discovery and token calls. One is created if omitted.
33
+ keys: Key resolver. Built from the config if omitted.
34
+ discovery: Pre-fetched metadata, for tests or a pinned deployment.
35
+
36
+ """
37
+ self.config = config
38
+ self.client = client or httpx.Client(timeout=5.0)
39
+ self.keys = keys or KeyResolver(
40
+ HttpJwksSource(config, self.client), max_age_seconds=config.jwks_max_age_seconds
41
+ )
42
+ self._discovery = discovery
43
+
44
+ @property
45
+ def discovery(self) -> Discovery:
46
+ """The provider's metadata, fetched once and cached.
47
+
48
+ Raises:
49
+ DiscoveryError: The issuer is unreachable or its document is unusable.
50
+
51
+ """
52
+ if self._discovery is None:
53
+ self._discovery = fetch_discovery(self.config, self.client)
54
+ return self._discovery
@@ -0,0 +1,77 @@
1
+ """The provider's metadata — the single place the broker's shape is learned.
2
+
3
+ Endpoints are never hand-built from the issuer. That is what keeps the module honest about
4
+ being broker-agnostic: point `issuer` elsewhere and every URL follows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import httpx
12
+
13
+ from eager_auth.config import AuthConfig
14
+ from eager_auth.errors import DiscoveryError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Discovery:
19
+ """The parts of the discovery document this module uses."""
20
+
21
+ issuer: str
22
+ authorization_endpoint: str
23
+ token_endpoint: str
24
+ jwks_uri: str
25
+ end_session_endpoint: str | None = None
26
+
27
+ @classmethod
28
+ def from_dict(cls, document: dict, *, expected_issuer: str) -> Discovery:
29
+ """Validate a metadata document.
30
+
31
+ Raises:
32
+ DiscoveryError: If the issuer disagrees or a required endpoint is missing.
33
+
34
+ """
35
+ issuer = document.get("issuer")
36
+ if issuer != expected_issuer:
37
+ # Metadata naming a different issuer than the URL it came from is misconfigured or
38
+ # spoofed. Either way, refuse to trust its endpoints.
39
+ raise DiscoveryError(
40
+ f"discovery issuer {issuer!r} does not match configured {expected_issuer!r}"
41
+ )
42
+ missing = [
43
+ field
44
+ for field in ("authorization_endpoint", "token_endpoint", "jwks_uri")
45
+ if not document.get(field)
46
+ ]
47
+ if missing:
48
+ raise DiscoveryError(f"discovery document is missing {', '.join(missing)}")
49
+ return cls(
50
+ issuer=str(issuer),
51
+ authorization_endpoint=str(document["authorization_endpoint"]),
52
+ token_endpoint=str(document["token_endpoint"]),
53
+ jwks_uri=str(document["jwks_uri"]),
54
+ end_session_endpoint=(
55
+ str(document["end_session_endpoint"])
56
+ if document.get("end_session_endpoint")
57
+ else None
58
+ ),
59
+ )
60
+
61
+
62
+ def fetch_discovery(config: AuthConfig, client: httpx.Client) -> Discovery:
63
+ """Read `{issuer}/.well-known/openid-configuration`.
64
+
65
+ Raises:
66
+ DiscoveryError: On any transport, parse or validation failure.
67
+
68
+ """
69
+ try:
70
+ response = client.get(config.discovery_url)
71
+ response.raise_for_status()
72
+ document = response.json()
73
+ except (httpx.HTTPError, ValueError) as exc:
74
+ raise DiscoveryError(f"cannot read {config.discovery_url}: {exc}") from exc
75
+ if not isinstance(document, dict):
76
+ raise DiscoveryError("discovery document is not an object")
77
+ return Discovery.from_dict(document, expected_issuer=config.issuer)
eager_auth/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ """Typed error hierarchy — see CONTRACT.md section 4.
2
+
3
+ Callers need to tell "your session expired" from "this token was never meant for us"
4
+ from "the identity provider is down", so every failure mode gets its own class.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class AuthError(Exception):
11
+ """Base class for every failure this package raises."""
12
+
13
+
14
+ class ConfigError(AuthError):
15
+ """Configuration is unusable. Raised at construction time, never per-request."""
16
+
17
+
18
+ class DiscoveryError(AuthError):
19
+ """The issuer's discovery document or JWKS could not be fetched.
20
+
21
+ Deliberately NOT an InvalidTokenError: the token may be perfectly good and the
22
+ broker merely unreachable, so a caller may want to retry rather than bounce the
23
+ user to /login.
24
+ """
25
+
26
+
27
+ class InvalidTokenError(AuthError):
28
+ """The token was rejected. Base class for every verification failure."""
29
+
30
+
31
+ class MalformedTokenError(InvalidTokenError):
32
+ """Undecodable, structurally broken, missing `sub`, or not yet valid."""
33
+
34
+
35
+ class UnsupportedAlgorithmError(InvalidTokenError):
36
+ """Header `alg` is symmetric or `none`.
37
+
38
+ Rejected before any key lookup: an attacker must not be able to downgrade us to a
39
+ symmetric secret we do not hold.
40
+ """
41
+
42
+
43
+ class UnknownKeyError(InvalidTokenError):
44
+ """No key in the issuer's JWKS matches the token's `kid`, even after a refresh."""
45
+
46
+
47
+ class BadSignatureError(InvalidTokenError):
48
+ """A known key was found and the signature did not verify against it."""
49
+
50
+
51
+ class ExpiredTokenError(InvalidTokenError):
52
+ """`exp` is in the past, beyond the configured clock skew."""
53
+
54
+
55
+ class WrongIssuerError(InvalidTokenError):
56
+ """`iss` does not equal the configured issuer."""
57
+
58
+
59
+ class WrongAudienceError(InvalidTokenError):
60
+ """`aud` does not contain the configured audience."""
61
+
62
+
63
+ class NonceMismatchError(InvalidTokenError):
64
+ """A nonce was expected and the token's claim is absent or different."""
65
+
66
+
67
+ class InvalidSessionError(AuthError):
68
+ """The session cookie could not be opened: tampered, wrong key, or expired.
69
+
70
+ Not an InvalidTokenError — this is our own envelope failing, not a broker token.
71
+ """
72
+
73
+
74
+ class StateMismatchError(AuthError):
75
+ """The callback's `state` does not match the pending login. Treated as CSRF."""
76
+
77
+
78
+ class TokenExchangeError(AuthError):
79
+ """The token endpoint refused or malformed the exchange.
80
+
81
+ Like DiscoveryError, this means the broker misbehaved rather than the user, so callers
82
+ may retry instead of bouncing to /login.
83
+ """
84
+
85
+
86
+ class RefreshRejectedError(AuthError):
87
+ """The broker refused to renew the session — the user must log in again.
88
+
89
+ Distinct from TokenExchangeError on purpose. `invalid_grant` means the refresh token is
90
+ spent, revoked or expired, so retrying is pointless and the only cure is a new login.
91
+ Any other failure is the broker misbehaving, where a retry is reasonable.
92
+ """