pipefy-auth 0.3.0a1__tar.gz

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.
Files changed (34) hide show
  1. pipefy_auth-0.3.0a1/.gitignore +56 -0
  2. pipefy_auth-0.3.0a1/PKG-INFO +31 -0
  3. pipefy_auth-0.3.0a1/README.md +16 -0
  4. pipefy_auth-0.3.0a1/pyproject.toml +50 -0
  5. pipefy_auth-0.3.0a1/src/pipefy_auth/__init__.py +110 -0
  6. pipefy_auth-0.3.0a1/src/pipefy_auth/_http.py +33 -0
  7. pipefy_auth-0.3.0a1/src/pipefy_auth/bearer.py +91 -0
  8. pipefy_auth-0.3.0a1/src/pipefy_auth/discovery.py +150 -0
  9. pipefy_auth-0.3.0a1/src/pipefy_auth/flow.py +213 -0
  10. pipefy_auth-0.3.0a1/src/pipefy_auth/identity.py +27 -0
  11. pipefy_auth-0.3.0a1/src/pipefy_auth/locks.py +117 -0
  12. pipefy_auth-0.3.0a1/src/pipefy_auth/loopback.py +155 -0
  13. pipefy_auth-0.3.0a1/src/pipefy_auth/pkce.py +29 -0
  14. pipefy_auth-0.3.0a1/src/pipefy_auth/refresh.py +206 -0
  15. pipefy_auth-0.3.0a1/src/pipefy_auth/resolver.py +229 -0
  16. pipefy_auth-0.3.0a1/src/pipefy_auth/responses.py +126 -0
  17. pipefy_auth-0.3.0a1/src/pipefy_auth/revoke.py +68 -0
  18. pipefy_auth-0.3.0a1/src/pipefy_auth/settings.py +513 -0
  19. pipefy_auth-0.3.0a1/src/pipefy_auth/storage.py +220 -0
  20. pipefy_auth-0.3.0a1/src/pipefy_auth/verification.py +187 -0
  21. pipefy_auth-0.3.0a1/tests/conftest.py +58 -0
  22. pipefy_auth-0.3.0a1/tests/test_bearer.py +258 -0
  23. pipefy_auth-0.3.0a1/tests/test_discovery.py +63 -0
  24. pipefy_auth-0.3.0a1/tests/test_http.py +51 -0
  25. pipefy_auth-0.3.0a1/tests/test_jwt_validation_settings.py +105 -0
  26. pipefy_auth-0.3.0a1/tests/test_locks.py +158 -0
  27. pipefy_auth-0.3.0a1/tests/test_refresh.py +519 -0
  28. pipefy_auth-0.3.0a1/tests/test_refresh_concurrent.py +139 -0
  29. pipefy_auth-0.3.0a1/tests/test_resolver.py +332 -0
  30. pipefy_auth-0.3.0a1/tests/test_responses.py +239 -0
  31. pipefy_auth-0.3.0a1/tests/test_settings_back_compat.py +265 -0
  32. pipefy_auth-0.3.0a1/tests/test_settings_toml_source.py +149 -0
  33. pipefy_auth-0.3.0a1/tests/test_storage_backend.py +236 -0
  34. pipefy_auth-0.3.0a1/tests/test_verification.py +243 -0
@@ -0,0 +1,56 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Ruff stuff:
10
+ .ruff_cache/
11
+
12
+ # Unit test / coverage reports
13
+ htmlcov/
14
+ .tox/
15
+ .nox/
16
+ .coverage
17
+ .coverage.*
18
+ .cache
19
+ nosetests.xml
20
+ coverage.xml
21
+ *.cover
22
+ *.py,cover
23
+ .hypothesis/
24
+ .pytest_cache/
25
+ cover/
26
+
27
+ # Environments
28
+ .env
29
+ .venv
30
+ env/
31
+ venv/
32
+ ENV/
33
+ env.bak/
34
+ venv.bak/
35
+ .mise.toml
36
+
37
+ # Ralph Wiggum (local autonomous runs; state must not land in git)
38
+ .ralph/
39
+ RALPH_TASK.md
40
+
41
+ # IDEs and editors
42
+ .cursor/
43
+ .claude/
44
+ # Cursor MCP workspace config (machine-specific paths or local server entries).
45
+ # Carve-out: repo-root .mcp.json is the Claude Code plugin's MCP server config
46
+ # (auto-discovered at plugin root) and is intentionally tracked.
47
+ .mcp.json
48
+ !/.mcp.json
49
+
50
+ # Tracked release tooling only (ignore ad-hoc local scripts and smoke artifacts)
51
+ scripts/*
52
+ !scripts/bump_version.py
53
+
54
+ # macOS
55
+ .DS_Store
56
+ **/.DS_Store
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipefy-auth
3
+ Version: 0.3.0a1
4
+ Summary: Shared OAuth/keychain helpers for Pipefy CLI and MCP server.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx-auth>=0.23.1
7
+ Requires-Dist: httpx>=0.28
8
+ Requires-Dist: keyring>=24
9
+ Requires-Dist: keyrings-alt>=5
10
+ Requires-Dist: pipefy-infra
11
+ Requires-Dist: pydantic-settings>=2.8.1
12
+ Requires-Dist: pydantic<3,>=2.13.4
13
+ Requires-Dist: pyjwt[crypto]>=2.8
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pipefy-auth
17
+
18
+ Shared OAuth + keychain helpers for Pipefy CLI and MCP server.
19
+
20
+ ## What lives here
21
+
22
+ - **`storage`** — keychain-backed `StoredSession` (one entry per `(issuer, client_id)` tuple, under the OS keychain service name `pipefy`).
23
+ - **`flow`** — OAuth 2.0 Authorization Code with PKCE login flow.
24
+ - **`refresh`** — refresh-token grant + eager pre-use freshness check (`ensure_fresh_session`).
25
+ - **`discovery`** — OIDC `.well-known/openid-configuration` fetch + validation.
26
+ - **`revoke`** — IdP-side token invalidation (RFC 7009).
27
+ - **`identity`** — `OidcClient` dataclass + the `DEFAULT_AUTH_CLIENT_ID` constant (the registered Keycloak public client_id).
28
+
29
+ ## Consumers
30
+
31
+ `pipefy-cli` and `pipefy-mcp-server` both depend on this package and read the same keychain entry, so a single `pipefy auth login` serves both binaries.
@@ -0,0 +1,16 @@
1
+ # pipefy-auth
2
+
3
+ Shared OAuth + keychain helpers for Pipefy CLI and MCP server.
4
+
5
+ ## What lives here
6
+
7
+ - **`storage`** — keychain-backed `StoredSession` (one entry per `(issuer, client_id)` tuple, under the OS keychain service name `pipefy`).
8
+ - **`flow`** — OAuth 2.0 Authorization Code with PKCE login flow.
9
+ - **`refresh`** — refresh-token grant + eager pre-use freshness check (`ensure_fresh_session`).
10
+ - **`discovery`** — OIDC `.well-known/openid-configuration` fetch + validation.
11
+ - **`revoke`** — IdP-side token invalidation (RFC 7009).
12
+ - **`identity`** — `OidcClient` dataclass + the `DEFAULT_AUTH_CLIENT_ID` constant (the registered Keycloak public client_id).
13
+
14
+ ## Consumers
15
+
16
+ `pipefy-cli` and `pipefy-mcp-server` both depend on this package and read the same keychain entry, so a single `pipefy auth login` serves both binaries.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "pipefy-auth"
3
+ dynamic = ["version"]
4
+ description = "Shared OAuth/keychain helpers for Pipefy CLI and MCP server."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "httpx>=0.28",
9
+ "httpx-auth>=0.23.1",
10
+ "keyring>=24",
11
+ "keyrings.alt>=5",
12
+ "pipefy-infra",
13
+ # RS256 validation of inbound bearers (resource-server role); [crypto] pulls cryptography.
14
+ "pyjwt[crypto]>=2.8",
15
+ "pydantic>=2.13.4,<3",
16
+ "pydantic-settings>=2.8.1",
17
+ ]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8.3.5",
22
+ "pytest-asyncio>=0.26.0",
23
+ "ruff>=0.11.4",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/pipefy_auth"]
32
+
33
+ [tool.hatch.version]
34
+ path = "src/pipefy_auth/__init__.py"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ pythonpath = ["src", "tests"]
39
+ markers = [
40
+ "unit: marks a test as a unit test",
41
+ ]
42
+
43
+ [tool.ruff]
44
+ extend = "../../pyproject.toml"
45
+ lint.extend-select = ["TID251"]
46
+
47
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
48
+ "pipefy_cli".msg = "pipefy_auth must not import the CLI package."
49
+ "pipefy_mcp".msg = "pipefy_auth must not import the MCP package."
50
+ "pipefy_sdk".msg = "pipefy_auth must not import the SDK package."
@@ -0,0 +1,110 @@
1
+ """Shared OAuth + keychain helpers for Pipefy CLI and MCP server.
2
+
3
+ Owns the keychain-backed user session (``StoredSession``), the OIDC discovery /
4
+ authorization-code-with-PKCE login flow, the refresh-token grant, and the OIDC
5
+ client identity (``OidcClient``, ``DEFAULT_AUTH_CLIENT_ID``). Consumers
6
+ (``pipefy_cli`` and ``pipefy_mcp``) build an authenticated client by passing the
7
+ artifacts surfaced here to the SDK.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.3.0-alpha.1"
13
+
14
+ from pipefy_auth.bearer import (
15
+ RefreshableBearerAuth,
16
+ StaticBearerAuth,
17
+ )
18
+ from pipefy_auth.discovery import (
19
+ DiscoveryPolicy,
20
+ ProviderMetadata,
21
+ fetch_provider_metadata,
22
+ )
23
+ from pipefy_auth.flow import LoginError, LoginResult, run_login
24
+ from pipefy_auth.identity import DEFAULT_AUTH_CLIENT_ID, OidcClient
25
+ from pipefy_auth.refresh import (
26
+ RefreshError,
27
+ ensure_fresh_session,
28
+ refresh_access_token,
29
+ )
30
+ from pipefy_auth.resolver import (
31
+ ResolvedAuth,
32
+ ServiceAccount,
33
+ ServiceAccountAuth,
34
+ StaticTokenAuth,
35
+ StoredSessionAuth,
36
+ build_httpx_auth,
37
+ detect_pipefy_auth_methods,
38
+ missing_auth_message,
39
+ resolve_pipefy_auth,
40
+ )
41
+ from pipefy_auth.responses import OAuthErrorResponse, TokenResponse
42
+ from pipefy_auth.revoke import (
43
+ RevocationError,
44
+ RevocationUnsupportedError,
45
+ revoke_session,
46
+ )
47
+ from pipefy_auth.settings import AuthSettings, JwtValidationSettings
48
+ from pipefy_auth.storage import (
49
+ SessionDeleteError,
50
+ StoredSession,
51
+ configure_keychain_backend,
52
+ delete_session,
53
+ keychain_backend_name,
54
+ keychain_key,
55
+ load_session,
56
+ store_session,
57
+ )
58
+ from pipefy_auth.verification import (
59
+ AudiencePolicy,
60
+ JwtValidator,
61
+ RequireAudience,
62
+ SkipAudience,
63
+ TokenValidationError,
64
+ )
65
+
66
+ __all__ = [
67
+ "AudiencePolicy",
68
+ "AuthSettings",
69
+ "JwtValidationSettings",
70
+ "RequireAudience",
71
+ "SkipAudience",
72
+ "DEFAULT_AUTH_CLIENT_ID",
73
+ "DiscoveryPolicy",
74
+ "JwtValidator",
75
+ "LoginError",
76
+ "LoginResult",
77
+ "OAuthErrorResponse",
78
+ "OidcClient",
79
+ "ProviderMetadata",
80
+ "RefreshError",
81
+ "RefreshableBearerAuth",
82
+ "RevocationError",
83
+ "RevocationUnsupportedError",
84
+ "ResolvedAuth",
85
+ "ServiceAccount",
86
+ "ServiceAccountAuth",
87
+ "SessionDeleteError",
88
+ "StaticBearerAuth",
89
+ "StaticTokenAuth",
90
+ "StoredSession",
91
+ "StoredSessionAuth",
92
+ "TokenResponse",
93
+ "TokenValidationError",
94
+ "__version__",
95
+ "build_httpx_auth",
96
+ "configure_keychain_backend",
97
+ "delete_session",
98
+ "detect_pipefy_auth_methods",
99
+ "ensure_fresh_session",
100
+ "fetch_provider_metadata",
101
+ "keychain_backend_name",
102
+ "keychain_key",
103
+ "load_session",
104
+ "missing_auth_message",
105
+ "refresh_access_token",
106
+ "resolve_pipefy_auth",
107
+ "revoke_session",
108
+ "run_login",
109
+ "store_session",
110
+ ]
@@ -0,0 +1,33 @@
1
+ """Shared httpx.Client context-manager helper for the OAuth flow modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager, nullcontext
6
+ from typing import Iterator
7
+
8
+ import httpx
9
+ from pipefy_infra.telemetry import auth_telemetry_headers
10
+
11
+
12
+ @contextmanager
13
+ def http_client(
14
+ provided: httpx.Client | None, *, timeout: float
15
+ ) -> Iterator[httpx.Client]:
16
+ """Yield ``provided`` (without closing it) or a fresh ``httpx.Client``.
17
+
18
+ The fresh client carries the auth telemetry headers so OAuth traffic to the
19
+ IdP is attributable to a pipefy-auth version rather than anonymous httpx.
20
+ """
21
+ if provided is not None:
22
+ with nullcontext(provided) as client:
23
+ yield client
24
+ return
25
+ from pipefy_auth import __version__
26
+
27
+ with httpx.Client(
28
+ timeout=timeout, headers=auth_telemetry_headers(version=__version__)
29
+ ) as client:
30
+ yield client
31
+
32
+
33
+ __all__ = ["http_client"]
@@ -0,0 +1,91 @@
1
+ """``httpx.Auth`` adapters that attach ``Authorization: Bearer …`` headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, Callable, Generator
7
+
8
+ from httpx import Auth, Request, Response
9
+
10
+
11
+ class StaticBearerAuth(Auth):
12
+ """Attach a fixed ``Authorization: Bearer …`` header."""
13
+
14
+ def __init__(self, token: str) -> None:
15
+ self._token = token
16
+
17
+ def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
18
+ request.headers["Authorization"] = f"Bearer {self._token}"
19
+ yield request
20
+
21
+
22
+ class RefreshableBearerAuth(Auth):
23
+ """Per-request bearer + reactive refresh-on-401 retry.
24
+
25
+ Pairs per-request token rotation (the provider is re-called on every request,
26
+ so a keychain-backed refresh in :func:`ensure_fresh_session` is observed
27
+ without rebuilding the SDK client) with a 401 safety net: when an API call
28
+ returns 401, ``force_refresh`` is
29
+ invoked to obtain a new bearer and the request is retried exactly once. If
30
+ it returns ``None`` or the same token, the 401 propagates so callers surface
31
+ a "session expired, re-login" error instead of looping. ``force_refresh`` is
32
+ trusted to return ``str | None`` — translating an IdP failure (``RefreshError``)
33
+ into ``None`` is the wiring's job, not this class's.
34
+
35
+ The eager refresh path (``token_provider`` calling
36
+ :func:`pipefy_auth.refresh.ensure_fresh_session`) still handles the common
37
+ "token expired by our clock" case. This class fills the gap eager refresh
38
+ cannot see — IdP-side revocation, and the narrow race between the eager
39
+ check and the API call.
40
+
41
+ Concurrency model: under async fan-out, the internal lock **serializes**
42
+ both eager token reads (``token_provider``) and reactive refreshes
43
+ (``force_refresh``) — three concurrent 401s run three refreshes
44
+ back-to-back, not one shared refresh. Coalescing racing refreshes is
45
+ out of scope here; it's the responsibility of the refresh-token grant
46
+ path.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ token_provider: Callable[[], str],
53
+ force_refresh: Callable[[], str | None],
54
+ ) -> None:
55
+ self._token_provider = token_provider
56
+ self._force_refresh = force_refresh
57
+ self._async_lock = asyncio.Lock()
58
+
59
+ def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
60
+ token = self._token_provider()
61
+ request.headers["Authorization"] = f"Bearer {token}"
62
+ response = yield request
63
+ if response.status_code != 401:
64
+ return
65
+ new_token = self._force_refresh()
66
+ if new_token is None or new_token == token:
67
+ return
68
+ request.headers["Authorization"] = f"Bearer {new_token}"
69
+ yield request
70
+
71
+ async def async_auth_flow(
72
+ self, request: Request
73
+ ) -> AsyncGenerator[Request, Response]:
74
+ async with self._async_lock:
75
+ token = await asyncio.to_thread(self._token_provider)
76
+ request.headers["Authorization"] = f"Bearer {token}"
77
+ response = yield request
78
+ if response.status_code != 401:
79
+ return
80
+ async with self._async_lock:
81
+ new_token = await asyncio.to_thread(self._force_refresh)
82
+ if new_token is None or new_token == token:
83
+ return
84
+ request.headers["Authorization"] = f"Bearer {new_token}"
85
+ yield request
86
+
87
+
88
+ __all__ = [
89
+ "RefreshableBearerAuth",
90
+ "StaticBearerAuth",
91
+ ]
@@ -0,0 +1,150 @@
1
+ """OIDC discovery: fetch ``.well-known/openid-configuration`` from an issuer URL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import httpx
8
+ from pipefy_infra import security
9
+ from pipefy_infra.coerce import optional_str
10
+
11
+ from pipefy_auth import _http
12
+
13
+ DISCOVERY_PATH = "/.well-known/openid-configuration"
14
+ _DEFAULT_TIMEOUT = 10.0
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProviderMetadata:
19
+ """Subset of OIDC provider metadata the login flow needs.
20
+
21
+ ``end_session_endpoint`` is optional per OIDC Discovery 1.0: not every IdP
22
+ advertises it. ``auth logout`` soft-fails when it's absent (warns + clears
23
+ the local session only). ``jwks_uri`` is the signing-key set the
24
+ resource-server bearer verifier reads; it's likewise modeled optional so
25
+ callers that don't validate inbound tokens (the login flow) don't depend on
26
+ it.
27
+ """
28
+
29
+ issuer: str
30
+ authorization_endpoint: str
31
+ token_endpoint: str
32
+ end_session_endpoint: str | None = None
33
+ jwks_uri: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DiscoveryPolicy:
38
+ """Knobs governing how ``fetch_provider_metadata`` validates its inputs.
39
+
40
+ Bundled so future flags (cached metadata, custom timeouts) can grow here
41
+ without re-threading kwargs through every caller.
42
+ """
43
+
44
+ allow_insecure_urls: bool = False
45
+
46
+
47
+ def _normalize_issuer(issuer_url: str) -> str:
48
+ return issuer_url.rstrip("/")
49
+
50
+
51
+ def discovery_url(issuer_url: str) -> str:
52
+ """Return the well-known discovery URL for ``issuer_url``."""
53
+ return f"{_normalize_issuer(issuer_url)}{DISCOVERY_PATH}"
54
+
55
+
56
+ def fetch_provider_metadata(
57
+ issuer_url: str,
58
+ *,
59
+ policy: DiscoveryPolicy = DiscoveryPolicy(),
60
+ timeout: float = _DEFAULT_TIMEOUT,
61
+ client: httpx.Client | None = None,
62
+ ) -> ProviderMetadata:
63
+ """Fetch and parse the issuer's OIDC discovery document.
64
+
65
+ Validates that ``metadata.issuer`` matches ``issuer_url`` (per OIDC
66
+ Discovery 1.0 §4.3) and that the returned ``authorization_endpoint`` and
67
+ ``token_endpoint`` aren't pointing at internal hosts or non-HTTPS URLs —
68
+ a tampered or misconfigured discovery doc must not be allowed to redirect
69
+ the browser dance or token exchange to an attacker-controlled target.
70
+
71
+ Raises:
72
+ ValueError: When the discovery document is unreachable, malformed,
73
+ issuer-mismatched, or returns endpoint URLs that don't pass the
74
+ shared SSRF check. Message is user-facing.
75
+ """
76
+ url = discovery_url(issuer_url)
77
+ with _http.http_client(client, timeout=timeout) as http:
78
+ try:
79
+ response = http.get(url)
80
+ except httpx.HTTPError as exc:
81
+ raise ValueError(
82
+ f"Could not reach Pipefy auth server at {url}: {exc}"
83
+ ) from exc
84
+
85
+ if response.status_code != 200:
86
+ # Status-only; never echo the raw body. Discovery isn't OAuth so there's
87
+ # no RFC 6749 ``error`` field to surface, and a `[:N]` window of the
88
+ # body is the same echo-channel class scrubbed in ``OAuthErrorResponse``.
89
+ raise ValueError(f"OIDC discovery failed ({response.status_code}) at {url}")
90
+
91
+ try:
92
+ data = response.json()
93
+ except ValueError as exc:
94
+ raise ValueError(
95
+ f"OIDC discovery returned non-JSON body at {url}: {exc}"
96
+ ) from exc
97
+
98
+ required = ("issuer", "authorization_endpoint", "token_endpoint")
99
+ missing = [key for key in required if not data.get(key)]
100
+ if missing:
101
+ raise ValueError(
102
+ f"OIDC discovery at {url} is missing required fields: {', '.join(missing)}"
103
+ )
104
+
105
+ # OIDC Discovery 1.0 §4.3 — the ``issuer`` claim must match the URL the
106
+ # document was fetched from. Trailing slashes don't carry meaning here.
107
+ claimed_issuer = str(data["issuer"])
108
+ if _normalize_issuer(claimed_issuer) != _normalize_issuer(issuer_url):
109
+ raise ValueError(
110
+ f"OIDC discovery issuer mismatch: requested {issuer_url!r}, "
111
+ f"document claims {claimed_issuer!r}"
112
+ )
113
+
114
+ authorization_endpoint = str(data["authorization_endpoint"])
115
+ token_endpoint = str(data["token_endpoint"])
116
+ end_session_endpoint = optional_str(data.get("end_session_endpoint"))
117
+ jwks_uri = optional_str(data.get("jwks_uri"))
118
+
119
+ endpoints: list[tuple[str, str]] = [
120
+ ("authorization_endpoint", authorization_endpoint),
121
+ ("token_endpoint", token_endpoint),
122
+ ]
123
+ if end_session_endpoint is not None:
124
+ endpoints.append(("end_session_endpoint", end_session_endpoint))
125
+ if jwks_uri is not None:
126
+ endpoints.append(("jwks_uri", jwks_uri))
127
+ for field, value in endpoints:
128
+ try:
129
+ security.validate_https_url(
130
+ value, field, allow_insecure=policy.allow_insecure_urls
131
+ )
132
+ except ValueError as exc:
133
+ raise ValueError(f"OIDC discovery returned invalid {field}: {exc}") from exc
134
+
135
+ return ProviderMetadata(
136
+ issuer=claimed_issuer,
137
+ authorization_endpoint=authorization_endpoint,
138
+ token_endpoint=token_endpoint,
139
+ end_session_endpoint=end_session_endpoint,
140
+ jwks_uri=jwks_uri,
141
+ )
142
+
143
+
144
+ __all__ = [
145
+ "DISCOVERY_PATH",
146
+ "DiscoveryPolicy",
147
+ "ProviderMetadata",
148
+ "discovery_url",
149
+ "fetch_provider_metadata",
150
+ ]