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.
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]