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.
- pipefy_auth-0.3.0a1/.gitignore +56 -0
- pipefy_auth-0.3.0a1/PKG-INFO +31 -0
- pipefy_auth-0.3.0a1/README.md +16 -0
- pipefy_auth-0.3.0a1/pyproject.toml +50 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/__init__.py +110 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/_http.py +33 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/bearer.py +91 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/discovery.py +150 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/flow.py +213 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/identity.py +27 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/locks.py +117 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/loopback.py +155 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/pkce.py +29 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/refresh.py +206 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/resolver.py +229 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/responses.py +126 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/revoke.py +68 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/settings.py +513 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/storage.py +220 -0
- pipefy_auth-0.3.0a1/src/pipefy_auth/verification.py +187 -0
- pipefy_auth-0.3.0a1/tests/conftest.py +58 -0
- pipefy_auth-0.3.0a1/tests/test_bearer.py +258 -0
- pipefy_auth-0.3.0a1/tests/test_discovery.py +63 -0
- pipefy_auth-0.3.0a1/tests/test_http.py +51 -0
- pipefy_auth-0.3.0a1/tests/test_jwt_validation_settings.py +105 -0
- pipefy_auth-0.3.0a1/tests/test_locks.py +158 -0
- pipefy_auth-0.3.0a1/tests/test_refresh.py +519 -0
- pipefy_auth-0.3.0a1/tests/test_refresh_concurrent.py +139 -0
- pipefy_auth-0.3.0a1/tests/test_resolver.py +332 -0
- pipefy_auth-0.3.0a1/tests/test_responses.py +239 -0
- pipefy_auth-0.3.0a1/tests/test_settings_back_compat.py +265 -0
- pipefy_auth-0.3.0a1/tests/test_settings_toml_source.py +149 -0
- pipefy_auth-0.3.0a1/tests/test_storage_backend.py +236 -0
- 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
|
+
]
|