pipefy-auth 0.3.0a1__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.
- pipefy_auth/__init__.py +110 -0
- pipefy_auth/_http.py +33 -0
- pipefy_auth/bearer.py +91 -0
- pipefy_auth/discovery.py +150 -0
- pipefy_auth/flow.py +213 -0
- pipefy_auth/identity.py +27 -0
- pipefy_auth/locks.py +117 -0
- pipefy_auth/loopback.py +155 -0
- pipefy_auth/pkce.py +29 -0
- pipefy_auth/refresh.py +206 -0
- pipefy_auth/resolver.py +229 -0
- pipefy_auth/responses.py +126 -0
- pipefy_auth/revoke.py +68 -0
- pipefy_auth/settings.py +513 -0
- pipefy_auth/storage.py +220 -0
- pipefy_auth/verification.py +187 -0
- pipefy_auth-0.3.0a1.dist-info/METADATA +31 -0
- pipefy_auth-0.3.0a1.dist-info/RECORD +19 -0
- pipefy_auth-0.3.0a1.dist-info/WHEEL +4 -0
pipefy_auth/__init__.py
ADDED
|
@@ -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
|
+
]
|
pipefy_auth/_http.py
ADDED
|
@@ -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"]
|
pipefy_auth/bearer.py
ADDED
|
@@ -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
|
+
]
|
pipefy_auth/discovery.py
ADDED
|
@@ -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
|
+
]
|
pipefy_auth/flow.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Orchestrate the Authorization Code + PKCE login against a Pipefy OIDC issuer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import webbrowser
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Callable, cast
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from pipefy_auth import _http
|
|
15
|
+
from pipefy_auth.discovery import (
|
|
16
|
+
DiscoveryPolicy,
|
|
17
|
+
ProviderMetadata,
|
|
18
|
+
fetch_provider_metadata,
|
|
19
|
+
)
|
|
20
|
+
from pipefy_auth.loopback import CallbackResult, LoopbackCapture
|
|
21
|
+
from pipefy_auth.pkce import challenge_from_verifier, generate_verifier
|
|
22
|
+
from pipefy_auth.responses import (
|
|
23
|
+
OAuthErrorResponse,
|
|
24
|
+
TokenResponse,
|
|
25
|
+
_format_validation_error,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_DEFAULT_SCOPES = ("openid", "profile", "email", "offline_access")
|
|
29
|
+
_TOKEN_EXCHANGE_TIMEOUT_S = 30.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoginError(RuntimeError):
|
|
33
|
+
"""User-facing failure during the login flow (rendered verbatim by the CLI)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class LoginResult:
|
|
38
|
+
"""The OAuth token, plus the resolved issuer (post-discovery)."""
|
|
39
|
+
|
|
40
|
+
issuer: str
|
|
41
|
+
token: TokenResponse
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_authorization_url(
|
|
45
|
+
*,
|
|
46
|
+
metadata: ProviderMetadata,
|
|
47
|
+
client_id: str,
|
|
48
|
+
redirect_uri: str,
|
|
49
|
+
code_challenge: str,
|
|
50
|
+
state: str,
|
|
51
|
+
scopes: tuple[str, ...] = _DEFAULT_SCOPES,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Construct the URL the user's browser should open to begin login."""
|
|
54
|
+
params = {
|
|
55
|
+
"client_id": client_id,
|
|
56
|
+
"response_type": "code",
|
|
57
|
+
"redirect_uri": redirect_uri,
|
|
58
|
+
"scope": " ".join(scopes),
|
|
59
|
+
"state": state,
|
|
60
|
+
"code_challenge": code_challenge,
|
|
61
|
+
"code_challenge_method": "S256",
|
|
62
|
+
}
|
|
63
|
+
return f"{metadata.authorization_endpoint}?{urlencode(params)}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def exchange_code(
|
|
67
|
+
*,
|
|
68
|
+
metadata: ProviderMetadata,
|
|
69
|
+
client_id: str,
|
|
70
|
+
code: str,
|
|
71
|
+
redirect_uri: str,
|
|
72
|
+
code_verifier: str,
|
|
73
|
+
client: httpx.Client,
|
|
74
|
+
) -> TokenResponse:
|
|
75
|
+
"""Exchange an authorization code for tokens at the issuer's token endpoint."""
|
|
76
|
+
try:
|
|
77
|
+
response = client.post(
|
|
78
|
+
metadata.token_endpoint,
|
|
79
|
+
data={
|
|
80
|
+
"grant_type": "authorization_code",
|
|
81
|
+
"client_id": client_id,
|
|
82
|
+
"code": code,
|
|
83
|
+
"redirect_uri": redirect_uri,
|
|
84
|
+
"code_verifier": code_verifier,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
except httpx.HTTPError as exc:
|
|
88
|
+
raise LoginError(f"Token exchange request failed: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
if response.status_code != 200:
|
|
91
|
+
# ``error_description`` is free-form per RFC 6749 §5.2 — its content
|
|
92
|
+
# reflects the IdP's framing, and surfacing it at all is part of trusting
|
|
93
|
+
# the IdP. A length cap wouldn't change that (an attacker can truncate
|
|
94
|
+
# to fit any cap, and a tight cap kills legitimate error context).
|
|
95
|
+
raise LoginError(
|
|
96
|
+
OAuthErrorResponse.from_response(response).render(
|
|
97
|
+
fallback=f"Token endpoint returned HTTP {response.status_code}",
|
|
98
|
+
prefix="Token exchange failed",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
payload = response.json()
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise LoginError(f"Token endpoint returned non-JSON response: {exc}") from exc
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
raise LoginError("Token endpoint returned a non-object JSON payload.")
|
|
107
|
+
try:
|
|
108
|
+
return TokenResponse.from_payload(payload)
|
|
109
|
+
except ValidationError as exc:
|
|
110
|
+
raise LoginError(_format_validation_error(exc)) from exc
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise LoginError(str(exc)) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_login(
|
|
116
|
+
*,
|
|
117
|
+
issuer_url: str,
|
|
118
|
+
client_id: str,
|
|
119
|
+
scopes: tuple[str, ...] = _DEFAULT_SCOPES,
|
|
120
|
+
callback_timeout_s: float = 180.0,
|
|
121
|
+
open_browser: Callable[[str], bool] = webbrowser.open,
|
|
122
|
+
on_url: Callable[[str], None] | None = None,
|
|
123
|
+
http_client: httpx.Client | None = None,
|
|
124
|
+
discovery_policy: DiscoveryPolicy = DiscoveryPolicy(),
|
|
125
|
+
) -> LoginResult:
|
|
126
|
+
"""Run the full PKCE loopback login. Returns tokens; does **not** persist them.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
issuer_url: OIDC issuer URL (e.g. ``https://signin.pipefy.com/realms/pipefy``).
|
|
130
|
+
client_id: Public client id registered for the CLI.
|
|
131
|
+
scopes: Scopes to request. Must include ``offline_access`` for a refresh
|
|
132
|
+
token to be issued.
|
|
133
|
+
callback_timeout_s: Seconds to wait for the browser callback.
|
|
134
|
+
open_browser: Launch the browser at ``auth_url``. Returns ``True`` if a
|
|
135
|
+
browser was launched; ``False`` otherwise (``--no-browser`` mode or
|
|
136
|
+
the OS couldn't open one).
|
|
137
|
+
on_url: Called with ``auth_url`` only when ``open_browser`` returned
|
|
138
|
+
``False`` — i.e., the user needs to open the URL by hand. Pass
|
|
139
|
+
``None`` to skip the fallback (callers must then handle the
|
|
140
|
+
``False`` return themselves).
|
|
141
|
+
http_client: Optional pre-configured ``httpx.Client`` (testing). When
|
|
142
|
+
omitted, one client is created and reused for discovery + token
|
|
143
|
+
exchange so the same TLS connection can serve both requests.
|
|
144
|
+
discovery_policy: Validation knobs forwarded to
|
|
145
|
+
:func:`fetch_provider_metadata` (notably ``allow_insecure_urls``
|
|
146
|
+
for local-development IdPs over http / private IPs).
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
LoginError: For any user-visible failure (discovery, state mismatch,
|
|
150
|
+
token exchange).
|
|
151
|
+
TimeoutError: When no browser callback arrives in time.
|
|
152
|
+
"""
|
|
153
|
+
with _http.http_client(http_client, timeout=_TOKEN_EXCHANGE_TIMEOUT_S) as http:
|
|
154
|
+
try:
|
|
155
|
+
metadata = fetch_provider_metadata(
|
|
156
|
+
issuer_url, policy=discovery_policy, client=http
|
|
157
|
+
)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise LoginError(str(exc)) from exc
|
|
160
|
+
|
|
161
|
+
# Bind the loopback server *before* opening the browser so no other
|
|
162
|
+
# process can grab the ephemeral port in between. The `with` block
|
|
163
|
+
# guarantees the socket is released even if a step before
|
|
164
|
+
# `await_callback` raises.
|
|
165
|
+
with LoopbackCapture() as capture:
|
|
166
|
+
verifier = generate_verifier()
|
|
167
|
+
state = secrets.token_urlsafe(24)
|
|
168
|
+
auth_url = build_authorization_url(
|
|
169
|
+
metadata=metadata,
|
|
170
|
+
client_id=client_id,
|
|
171
|
+
redirect_uri=capture.redirect_uri,
|
|
172
|
+
code_challenge=challenge_from_verifier(verifier),
|
|
173
|
+
state=state,
|
|
174
|
+
scopes=scopes,
|
|
175
|
+
)
|
|
176
|
+
if not open_browser(auth_url) and on_url is not None:
|
|
177
|
+
on_url(auth_url)
|
|
178
|
+
|
|
179
|
+
callback = capture.await_callback(timeout=callback_timeout_s)
|
|
180
|
+
_ensure_callback_ok(callback, expected_state=state)
|
|
181
|
+
code = cast(str, callback.code)
|
|
182
|
+
|
|
183
|
+
token = exchange_code(
|
|
184
|
+
metadata=metadata,
|
|
185
|
+
client_id=client_id,
|
|
186
|
+
code=code,
|
|
187
|
+
redirect_uri=capture.redirect_uri,
|
|
188
|
+
code_verifier=verifier,
|
|
189
|
+
client=http,
|
|
190
|
+
)
|
|
191
|
+
return LoginResult(issuer=metadata.issuer, token=token)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ensure_callback_ok(callback: CallbackResult, *, expected_state: str) -> None:
|
|
195
|
+
if callback.error:
|
|
196
|
+
detail = callback.error_description or ""
|
|
197
|
+
suffix = f": {detail}" if detail else ""
|
|
198
|
+
raise LoginError(f"Authorization server returned {callback.error}{suffix}")
|
|
199
|
+
if callback.state != expected_state:
|
|
200
|
+
raise LoginError(
|
|
201
|
+
"State mismatch on OAuth callback (possible CSRF). Aborting login."
|
|
202
|
+
)
|
|
203
|
+
if not callback.code:
|
|
204
|
+
raise LoginError("OAuth callback did not include an authorization code.")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"LoginError",
|
|
209
|
+
"LoginResult",
|
|
210
|
+
"build_authorization_url",
|
|
211
|
+
"exchange_code",
|
|
212
|
+
"run_login",
|
|
213
|
+
]
|
pipefy_auth/identity.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""OIDC client identity shared by every consumer of the stored user session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# Refresh tokens are bound to the client_id that obtained them, so this value
|
|
8
|
+
# is fixed across consumers (CLI, MCP, …) and not user-configurable.
|
|
9
|
+
DEFAULT_AUTH_CLIENT_ID = "pipefy-cli"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class OidcClient:
|
|
14
|
+
"""OIDC client identity: issuer URL + the public client id registered there.
|
|
15
|
+
|
|
16
|
+
Presence of an :class:`OidcClient` is what gates the stored-session method
|
|
17
|
+
of the credential precedence chain.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
issuer_url: str
|
|
21
|
+
client_id: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_AUTH_CLIENT_ID",
|
|
26
|
+
"OidcClient",
|
|
27
|
+
]
|