agentic-runner 3.7.0__tar.gz → 3.9.0__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.
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/PKG-INFO +1 -1
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/activities.py +53 -1
- agentic_runner-3.9.0/src/agentic_runner/integrations/oauth/__init__.py +68 -0
- agentic_runner-3.9.0/src/agentic_runner/integrations/oauth/fake_provider.py +134 -0
- agentic_runner-3.9.0/src/agentic_runner/oauth_connectors.py +592 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/sealed_box.py +95 -1
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/service.py +57 -4
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/_runtime_support.py +1 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/acp_runtime.py +123 -19
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/agent_runtime.py +3 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/settings.py +10 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/.gitignore +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/LICENSE +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/README.md +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/pyproject.toml +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/usage_windows.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -124,6 +124,7 @@ from agentic_runner.workers._runtime_support import (
|
|
|
124
124
|
bound_text,
|
|
125
125
|
bound_text_tail,
|
|
126
126
|
)
|
|
127
|
+
from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
|
|
127
128
|
from agentic_runner.workers.agent_runtime import (
|
|
128
129
|
AgentRuntime,
|
|
129
130
|
AuthMode,
|
|
@@ -595,6 +596,8 @@ class _RuntimeContextState:
|
|
|
595
596
|
# PRD decision 12: what a runtime asked per command does when the command policy has
|
|
596
597
|
# no answer. Carried on the same `command_policy` blob as the ceiling above.
|
|
597
598
|
permission_fallback: PermissionFallback = PermissionFallback.DENY
|
|
599
|
+
# Local-agents 18: the Profile-named ACP command, for a kind with no pinned bridge.
|
|
600
|
+
acp_command: tuple[str, ...] = ()
|
|
598
601
|
# Pushed on change and applied on receipt (ADR-0011 §11, PRD issue 44): the evaluator
|
|
599
602
|
# is local, so a narrowing lands on the next verb this state is read for.
|
|
600
603
|
grant_snapshot: GrantSnapshot = UNENFORCED_SNAPSHOT
|
|
@@ -2053,6 +2056,7 @@ class RunnerRalphActivities:
|
|
|
2053
2056
|
egress_allow_list=attempt.egress_allow_list,
|
|
2054
2057
|
auth_mode=attempt.auth_mode,
|
|
2055
2058
|
permission_fallback=runtime_state.permission_fallback,
|
|
2059
|
+
acp_command=runtime_state.acp_command,
|
|
2056
2060
|
prompt=attempt.skills_preamble
|
|
2057
2061
|
+ _directive_prompt(
|
|
2058
2062
|
completion_criteria=runtime_state.completion_criteria,
|
|
@@ -3008,10 +3012,55 @@ class RunnerRalphActivities:
|
|
|
3008
3012
|
Fails closed (local-agents 01): a kind this process does not serve fails the
|
|
3009
3013
|
Directive non-retryably, with Evidence naming the kind and the Runner, and never
|
|
3010
3014
|
falls back to another runtime -- routing should not have sent it here, and a
|
|
3011
|
-
retry on this Runner would find the same registry.
|
|
3015
|
+
retry on this Runner would find the same registry. So does a Profile-named ACP
|
|
3016
|
+
command for a kind whose bridge the Runner pins (local-agents 18): the platform
|
|
3017
|
+
never sends one, and the pinned bridge is the only thing that runs for that kind --
|
|
3018
|
+
and, for any other kind, a command whose ``argv[0]`` is not the executable the host
|
|
3019
|
+
pinned for it.
|
|
3012
3020
|
"""
|
|
3013
3021
|
|
|
3022
|
+
if state.acp_command and state.cli_kind in ACP_BRIDGES:
|
|
3023
|
+
await _raise_recorded(
|
|
3024
|
+
ApplicationError(
|
|
3025
|
+
f"the Runner pins the ACP bridge for {state.cli_kind!r}; a Profile-named "
|
|
3026
|
+
"ACP command is refused for it",
|
|
3027
|
+
non_retryable=True,
|
|
3028
|
+
),
|
|
3029
|
+
self._fastapi_client,
|
|
3030
|
+
work_record_id,
|
|
3031
|
+
source=AGENT_RUNTIME_EVIDENCE_SOURCE,
|
|
3032
|
+
actor=self._actor(),
|
|
3033
|
+
payload={
|
|
3034
|
+
"event": "directive.acp_command_refused",
|
|
3035
|
+
"cli_kind": state.cli_kind,
|
|
3036
|
+
# The executable's name only: its arguments may carry a secret.
|
|
3037
|
+
"executable": redact_secret_like_text(Path(state.acp_command[0]).name),
|
|
3038
|
+
},
|
|
3039
|
+
)
|
|
3014
3040
|
runtime = self._agent_runtimes.get(state.cli_kind)
|
|
3041
|
+
command_refusal = (
|
|
3042
|
+
runtime.acp_command_refusal(state.acp_command)
|
|
3043
|
+
if isinstance(runtime, AcpRuntime)
|
|
3044
|
+
else None
|
|
3045
|
+
)
|
|
3046
|
+
if command_refusal is not None:
|
|
3047
|
+
await _raise_recorded(
|
|
3048
|
+
ApplicationError(command_refusal, non_retryable=True),
|
|
3049
|
+
self._fastapi_client,
|
|
3050
|
+
work_record_id,
|
|
3051
|
+
source=AGENT_RUNTIME_EVIDENCE_SOURCE,
|
|
3052
|
+
actor=self._actor(),
|
|
3053
|
+
payload={
|
|
3054
|
+
"event": "directive.acp_command_refused",
|
|
3055
|
+
"cli_kind": state.cli_kind,
|
|
3056
|
+
"executable": (
|
|
3057
|
+
redact_secret_like_text(Path(state.acp_command[0]).name)
|
|
3058
|
+
if state.acp_command
|
|
3059
|
+
else None
|
|
3060
|
+
),
|
|
3061
|
+
"reason": command_refusal,
|
|
3062
|
+
},
|
|
3063
|
+
)
|
|
3015
3064
|
if runtime is not None:
|
|
3016
3065
|
return runtime
|
|
3017
3066
|
served = sorted(self._agent_runtimes)
|
|
@@ -3425,6 +3474,7 @@ class RunnerRalphActivities:
|
|
|
3425
3474
|
model=runtime_context.model,
|
|
3426
3475
|
memory_limit_bytes=_profile_memory_limit_bytes(runtime_context.command_policy),
|
|
3427
3476
|
permission_fallback=_profile_permission_fallback(runtime_context.command_policy),
|
|
3477
|
+
acp_command=tuple(runtime_context.acp_command or ()),
|
|
3428
3478
|
grant_snapshot=snapshot,
|
|
3429
3479
|
grant_snapshot_payload=snapshot_payload,
|
|
3430
3480
|
persona_slug=runtime_context.persona_slug,
|
|
@@ -4699,6 +4749,7 @@ class RunnerRalphActivities:
|
|
|
4699
4749
|
egress_allow_list=attempt.egress_allow_list,
|
|
4700
4750
|
auth_mode=attempt.auth_mode,
|
|
4701
4751
|
permission_fallback=state.permission_fallback,
|
|
4752
|
+
acp_command=state.acp_command,
|
|
4702
4753
|
prompt=attempt.skills_preamble
|
|
4703
4754
|
+ _learning_prompt(
|
|
4704
4755
|
request,
|
|
@@ -4960,6 +5011,7 @@ class RunnerRalphActivities:
|
|
|
4960
5011
|
egress_allow_list=attempt.egress_allow_list,
|
|
4961
5012
|
auth_mode=attempt.auth_mode,
|
|
4962
5013
|
permission_fallback=state.permission_fallback,
|
|
5014
|
+
acp_command=state.acp_command,
|
|
4963
5015
|
prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
|
|
4964
5016
|
base_branch=request.base_ref,
|
|
4965
5017
|
work_branch=state.work_branch,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The OAuth provider port: one form POST to a token or revocation endpoint (issue 30).
|
|
2
|
+
|
|
3
|
+
RFC 6749 §4.1.3 / §6 and RFC 7009 are each a form POST answered with JSON, so the port is
|
|
4
|
+
exactly that and the protocol lives in :mod:`agentic_runner.oauth_connectors`, where a
|
|
5
|
+
fake provider exercises all of it. ``HttpOAuthProvider`` is the Runner's own outbound HTTPS
|
|
6
|
+
-- the exchange never touches the control plane.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Final, Protocol
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HttpOAuthProvider",
|
|
19
|
+
"OAuthProvider",
|
|
20
|
+
"OAuthProviderError",
|
|
21
|
+
"ProviderResponse",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# One beat's budget: the exchange and a refresh run on the heartbeat, so a provider that
|
|
25
|
+
# hangs must cost a failed step and not a stale link.
|
|
26
|
+
_TIMEOUT: Final[float] = 10.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OAuthProviderError(RuntimeError):
|
|
30
|
+
"""The provider could not be reached or did not answer HTTP. Never carries a body."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ProviderResponse:
|
|
35
|
+
status: int
|
|
36
|
+
# repr=False: a token response is the one body that must never reach a log line.
|
|
37
|
+
body: Mapping[str, object] = field(default_factory=dict, repr=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OAuthProvider(Protocol):
|
|
41
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HttpOAuthProvider:
|
|
45
|
+
"""The real provider: HTTPS only, no redirects, JSON asked for.
|
|
46
|
+
|
|
47
|
+
Redirects are not followed because a 30x on a token endpoint would re-send the code
|
|
48
|
+
and the verifier to wherever it points. ``Accept: application/json`` because GitHub,
|
|
49
|
+
among others, answers form-encoded unless asked.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse:
|
|
53
|
+
try:
|
|
54
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=False) as client:
|
|
55
|
+
response = await client.post(
|
|
56
|
+
url, data=dict(form), headers={"Accept": "application/json"}
|
|
57
|
+
)
|
|
58
|
+
except httpx.HTTPError as error:
|
|
59
|
+
# The type only: an httpx error's text can carry the request URL, and a
|
|
60
|
+
# refresh's form is in no URL, but nothing here needs more than the class.
|
|
61
|
+
raise OAuthProviderError(type(error).__name__) from None
|
|
62
|
+
try:
|
|
63
|
+
body = response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
body = {}
|
|
66
|
+
return ProviderResponse(
|
|
67
|
+
status=response.status_code, body=body if isinstance(body, dict) else {}
|
|
68
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A fake OAuth provider that enforces what a real one does (issue 30's Runner tests).
|
|
2
|
+
|
|
3
|
+
Strict on purpose: a code is single-use and bound to its client, redirect and S256
|
|
4
|
+
challenge; a refresh token rotates and the old one stops working; revocation ends both.
|
|
5
|
+
A Runner that sent the wrong verifier, replayed a code or kept an old refresh token fails
|
|
6
|
+
here the way it would fail against a provider.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from urllib.parse import parse_qs, urlsplit
|
|
17
|
+
|
|
18
|
+
from agentic_runner.integrations.oauth import ProviderResponse
|
|
19
|
+
|
|
20
|
+
__all__ = ["FakeOAuthProvider"]
|
|
21
|
+
|
|
22
|
+
AUTHORIZE = "https://provider.test/authorize"
|
|
23
|
+
TOKEN = "https://provider.test/token"
|
|
24
|
+
REVOKE = "https://provider.test/revoke"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _Grant:
|
|
29
|
+
client_id: str
|
|
30
|
+
redirect_uri: str
|
|
31
|
+
challenge: str
|
|
32
|
+
scope: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FakeOAuthProvider:
|
|
37
|
+
client_id: str = "client-123"
|
|
38
|
+
client_secret: str | None = None
|
|
39
|
+
expires_in: int | None = 3600
|
|
40
|
+
authorize_endpoint: str = AUTHORIZE
|
|
41
|
+
token_endpoint: str = TOKEN
|
|
42
|
+
revocation_endpoint: str = REVOKE
|
|
43
|
+
requests: list[tuple[str, dict[str, str]]] = field(default_factory=list)
|
|
44
|
+
issued: list[str] = field(default_factory=list)
|
|
45
|
+
revoked: set[str] = field(default_factory=set)
|
|
46
|
+
_codes: dict[str, _Grant] = field(default_factory=dict)
|
|
47
|
+
# token -> the grant it belongs to; a refresh token also keeps the grant's scope.
|
|
48
|
+
_access: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
_refresh: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def consent(self, authorize_url: str) -> tuple[str, str]:
|
|
52
|
+
"""The person approves: the ``(code, state)`` the callback URL would carry."""
|
|
53
|
+
|
|
54
|
+
query = {key: values[0] for key, values in parse_qs(urlsplit(authorize_url).query).items()}
|
|
55
|
+
assert query["response_type"] == "code"
|
|
56
|
+
assert query["code_challenge_method"] == "S256"
|
|
57
|
+
code = f"code-{secrets.token_hex(8)}"
|
|
58
|
+
self._codes[code] = _Grant(
|
|
59
|
+
client_id=query["client_id"],
|
|
60
|
+
redirect_uri=query["redirect_uri"],
|
|
61
|
+
challenge=query["code_challenge"],
|
|
62
|
+
scope=query.get("scope", ""),
|
|
63
|
+
)
|
|
64
|
+
return code, query["state"]
|
|
65
|
+
|
|
66
|
+
def access_valid(self, token: str) -> bool:
|
|
67
|
+
return token in self._access and token not in self.revoked
|
|
68
|
+
|
|
69
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse:
|
|
70
|
+
self.requests.append((url, dict(form)))
|
|
71
|
+
if url == self.revocation_endpoint:
|
|
72
|
+
return self._revoke(form)
|
|
73
|
+
if url != self.token_endpoint:
|
|
74
|
+
return ProviderResponse(404)
|
|
75
|
+
if not self._client_ok(form):
|
|
76
|
+
return _error(401, "invalid_client")
|
|
77
|
+
if form.get("grant_type") == "authorization_code":
|
|
78
|
+
return self._exchange(form)
|
|
79
|
+
if form.get("grant_type") == "refresh_token":
|
|
80
|
+
return self._rotate(form)
|
|
81
|
+
return _error(400, "unsupported_grant_type")
|
|
82
|
+
|
|
83
|
+
def _client_ok(self, form: Mapping[str, str]) -> bool:
|
|
84
|
+
if form.get("client_id") != self.client_id:
|
|
85
|
+
return False
|
|
86
|
+
return self.client_secret is None or form.get("client_secret") == self.client_secret
|
|
87
|
+
|
|
88
|
+
def _exchange(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
89
|
+
grant = self._codes.pop(form.get("code", ""), None)
|
|
90
|
+
if grant is None or grant.redirect_uri != form.get("redirect_uri"):
|
|
91
|
+
return _error(400, "invalid_grant")
|
|
92
|
+
verifier = form.get("code_verifier", "")
|
|
93
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
94
|
+
if base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") != grant.challenge:
|
|
95
|
+
return _error(400, "invalid_grant")
|
|
96
|
+
return self._tokens(f"grant-{secrets.token_hex(8)}", grant.scope)
|
|
97
|
+
|
|
98
|
+
def _rotate(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
99
|
+
held = self._refresh.pop(form.get("refresh_token", ""), None)
|
|
100
|
+
if held is None:
|
|
101
|
+
return _error(400, "invalid_grant")
|
|
102
|
+
return self._tokens(*held)
|
|
103
|
+
|
|
104
|
+
def _tokens(self, grant_id: str, scope: str) -> ProviderResponse:
|
|
105
|
+
access = f"at-{secrets.token_hex(16)}"
|
|
106
|
+
refresh = f"rt-{secrets.token_hex(16)}"
|
|
107
|
+
self._access[access] = grant_id
|
|
108
|
+
self._refresh[refresh] = (grant_id, scope)
|
|
109
|
+
self.issued += [access, refresh]
|
|
110
|
+
body: dict[str, object] = {
|
|
111
|
+
"access_token": access,
|
|
112
|
+
"refresh_token": refresh,
|
|
113
|
+
"token_type": "Bearer",
|
|
114
|
+
"scope": scope,
|
|
115
|
+
}
|
|
116
|
+
if self.expires_in is not None:
|
|
117
|
+
body["expires_in"] = self.expires_in
|
|
118
|
+
return ProviderResponse(200, body)
|
|
119
|
+
|
|
120
|
+
def _revoke(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
121
|
+
if not self._client_ok(form):
|
|
122
|
+
return _error(401, "invalid_client")
|
|
123
|
+
token = form.get("token", "")
|
|
124
|
+
self.revoked.add(token)
|
|
125
|
+
# RFC 7009 §2.1: revoking a refresh token may revoke the grant's access tokens;
|
|
126
|
+
# this provider does, so the test can see the access token stop working.
|
|
127
|
+
held = self._refresh.pop(token, None)
|
|
128
|
+
if held is not None:
|
|
129
|
+
self.revoked.update(t for t, grant_id in self._access.items() if grant_id == held[0])
|
|
130
|
+
return ProviderResponse(200)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _error(status: int, code: str) -> ProviderResponse:
|
|
134
|
+
return ProviderResponse(status, {"error": code, "error_description": "fake provider"})
|