Skip to content

Commit e9563cf

Browse files
dcramercodex
andcommitted
Centralize capability auth completion normalization
Move callback/code parsing and validation into capability manager, add explicit auth completion error taxonomy, and pass normalized auth payloads to providers/bridge. Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 5b5b522 commit e9563cf

14 files changed

Lines changed: 328 additions & 49 deletions

specs/capabilities.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,10 +354,18 @@ Request params:
354354
{
355355
"flow_id": "caf_01...",
356356
"callback_url": "https://localhost/callback?code=...",
357+
"code": "optional-direct-auth-code",
357358
"context_token": "<signed-token>"
358359
}
359360
```
360361

362+
Normalization rules (host-owned, provider-independent):
363+
364+
- Accept `code`, `callback_url`, or both.
365+
- If both are provided and disagree, fail with `capability_auth_code_conflict`.
366+
- If callback `state` is present and mismatches stored flow state, fail with `capability_auth_state_mismatch`.
367+
- If no usable code can be resolved, fail with `capability_auth_code_missing`.
368+
361369
Response:
362370

363371
```json
@@ -426,6 +434,10 @@ See `specs/browser.md` for runtime bridge invariants.
426434
| Auth flow expired/invalid | `capability_auth_flow_invalid` |
427435
| Device auth flow expired | `capability_auth_flow_expired` |
428436
| Device auth flow denied by user | `capability_auth_flow_denied` |
437+
| Callback URL malformed | `capability_auth_callback_invalid` |
438+
| Callback/code mismatch | `capability_auth_code_conflict` |
439+
| Callback state mismatch | `capability_auth_state_mismatch` |
440+
| Missing authorization code | `capability_auth_code_missing` |
429441
| Invalid input schema | `capability_invalid_input` |
430442
| Upstream/provider unavailable | `capability_backend_unavailable` |
431443

specs/capability-auth.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The device code flow works identically across all providers.
3131
1. Auth flows work on any deployment: headless server, SSH session, Telegram bot.
3232
2. No browser or public URL is required on the machine running Ash.
3333
3. Users complete consent on their own device — phone, laptop, or any browser.
34-
4. Existing `auth_begin`/`auth_complete` (authorization code) flows continue to work unchanged.
34+
4. Existing caller-facing `auth_begin`/`auth_complete` flows continue to work; auth completion normalization is centralized in host capability manager.
3535
5. Skills detect flow type from `auth_begin` response and adapt UX accordingly.
3636

3737
## Device Code Flow
@@ -325,7 +325,8 @@ Update `src/ash/integrations/skills/capabilities/google/SKILL.md` auth section t
325325

326326
- `flow_type` defaults to `"authorization_code"` — existing bridges and skill flows keep working.
327327
- `auth_poll` is handled by the manager delegation wrapper: when the provider is missing or doesn't implement it, raises `CapabilityError("capability_invalid_input", "auth polling not supported by this provider")`.
328-
- Existing `auth_complete` with `code`/`callback_url` continues to work for redirect-based flows.
328+
- Existing caller-side `auth_complete` with `code`/`callback_url` continues to work for redirect-based flows.
329+
- Provider/bridge `auth_complete` consumes normalized `authorization_code` input; callback URL parsing is host-owned.
329330
- `SubprocessCapabilityProvider` handles missing `auth_poll` from bridges gracefully (bridge returns error, provider surfaces it).
330331
- New fields in `auth_begin` return dict (`flow_type`, `user_code`, `poll_interval_seconds`) are nullable/defaulted — callers that don't check them are unaffected.
331332

src/ash/capabilities/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from ash.capabilities.providers import (
2323
CapabilityAuthBeginResult,
24+
CapabilityAuthCompleteInput,
2425
CapabilityAuthCompleteResult,
2526
CapabilityAuthPollResult,
2627
CapabilityCallContext,
@@ -41,6 +42,7 @@
4142
"CapabilityProvider",
4243
"CapabilityCallContext",
4344
"CapabilityAuthBeginResult",
45+
"CapabilityAuthCompleteInput",
4446
"CapabilityAuthCompleteResult",
4547
"CapabilityAuthPollResult",
4648
"CapabilityAccount",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Central auth-complete input normalization for capability auth flows."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from urllib.parse import parse_qs, urlparse
7+
8+
9+
class AuthNormalizationError(ValueError):
10+
"""Normalization error with stable capability auth error code."""
11+
12+
def __init__(self, code: str, message: str) -> None:
13+
super().__init__(message)
14+
self.code = code
15+
16+
17+
@dataclass(slots=True)
18+
class NormalizedAuthCompletion:
19+
"""Canonical auth completion payload used by capability providers."""
20+
21+
authorization_code: str
22+
raw_callback_url: str | None
23+
state: str | None
24+
25+
26+
def normalize_auth_completion(
27+
*,
28+
callback_url: str | None,
29+
code: str | None,
30+
expected_state: str | None,
31+
) -> NormalizedAuthCompletion:
32+
"""Normalize callback URL / code inputs into one authorization code."""
33+
normalized_code = _optional_text(code)
34+
normalized_callback_url = _optional_text(callback_url)
35+
callback_code: str | None = None
36+
callback_state: str | None = None
37+
38+
if normalized_callback_url is not None:
39+
callback_code, callback_state = _parse_callback_url(normalized_callback_url)
40+
41+
if (
42+
normalized_code is not None
43+
and callback_code is not None
44+
and normalized_code != callback_code
45+
):
46+
raise AuthNormalizationError(
47+
"capability_auth_code_conflict",
48+
"code does not match callback_url code",
49+
)
50+
51+
if (
52+
expected_state is not None
53+
and callback_state is not None
54+
and callback_state != expected_state
55+
):
56+
raise AuthNormalizationError(
57+
"capability_auth_state_mismatch",
58+
"callback_url state does not match auth flow",
59+
)
60+
61+
authorization_code = normalized_code or callback_code
62+
if authorization_code is None:
63+
raise AuthNormalizationError(
64+
"capability_auth_code_missing",
65+
"either code or callback_url with code is required",
66+
)
67+
68+
return NormalizedAuthCompletion(
69+
authorization_code=authorization_code,
70+
raw_callback_url=normalized_callback_url,
71+
state=callback_state,
72+
)
73+
74+
75+
def _parse_callback_url(callback_url: str) -> tuple[str, str | None]:
76+
parsed = urlparse(callback_url)
77+
if not parsed.scheme or not parsed.netloc:
78+
raise AuthNormalizationError(
79+
"capability_auth_callback_invalid",
80+
"callback_url is not a valid URL",
81+
)
82+
83+
query = parse_qs(parsed.query)
84+
code = _optional_text((query.get("code") or [None])[0])
85+
if code is None:
86+
raise AuthNormalizationError(
87+
"capability_auth_code_missing",
88+
"callback_url missing code query parameter",
89+
)
90+
state = _optional_text((query.get("state") or [None])[0])
91+
return code, state
92+
93+
94+
def _optional_text(value: str | None) -> str | None:
95+
if value is None:
96+
return None
97+
text = str(value).strip()
98+
return text or None

src/ash/capabilities/manager.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111
from datetime import UTC, datetime, timedelta
1212
from typing import Any
1313

14+
from ash.capabilities.auth_normalization import (
15+
AuthNormalizationError,
16+
normalize_auth_completion,
17+
)
1418
from ash.capabilities.providers import (
1519
CapabilityAuthBeginResult,
20+
CapabilityAuthCompleteInput,
1621
CapabilityAuthCompleteResult,
1722
CapabilityAuthPollResult,
1823
CapabilityCallContext,
@@ -282,6 +287,7 @@ async def auth_begin(
282287
expires_at=expires_at,
283288
flow_state=dict(begin_result.flow_state),
284289
flow_type=flow_type,
290+
expected_callback_state=begin_result.expected_callback_state,
285291
)
286292

287293
result: dict[str, Any] = {
@@ -331,11 +337,6 @@ async def auth_complete(
331337
normalized_source_display_name = _optional_text(source_display_name)
332338
normalized_callback_url = _optional_text(callback_url)
333339
normalized_code = _optional_text(code)
334-
if not normalized_callback_url and not normalized_code:
335-
raise CapabilityError(
336-
"capability_invalid_input",
337-
"either callback_url or code is required",
338-
)
339340

340341
async with self._lock:
341342
self._prune_expired_flows_locked()
@@ -353,6 +354,14 @@ async def auth_complete(
353354
_, provider_impl = self._get_definition_and_provider_locked(
354355
flow.capability_id
355356
)
357+
try:
358+
normalized_completion = normalize_auth_completion(
359+
callback_url=normalized_callback_url,
360+
code=normalized_code,
361+
expected_state=flow.expected_callback_state,
362+
)
363+
except AuthNormalizationError as e:
364+
raise CapabilityError(e.code, str(e)) from e
356365

357366
call_context = CapabilityCallContext(
358367
user_id=normalized_user_id,
@@ -368,8 +377,11 @@ async def auth_complete(
368377
provider_impl,
369378
capability_id=flow.capability_id,
370379
flow_state=dict(flow.flow_state),
371-
callback_url=normalized_callback_url,
372-
code=normalized_code,
380+
completion=CapabilityAuthCompleteInput(
381+
authorization_code=normalized_completion.authorization_code,
382+
raw_callback_url=normalized_completion.raw_callback_url,
383+
state=normalized_completion.state,
384+
),
373385
account_hint=flow.account_hint,
374386
context=call_context,
375387
)
@@ -659,6 +671,7 @@ async def _provider_auth_begin(
659671
flow_type=result.flow_type or "authorization_code",
660672
user_code=result.user_code,
661673
poll_interval_seconds=result.poll_interval_seconds,
674+
expected_callback_state=result.expected_callback_state,
662675
)
663676

664677
async def _provider_auth_poll(
@@ -686,8 +699,7 @@ async def _provider_auth_complete(
686699
*,
687700
capability_id: str,
688701
flow_state: dict[str, Any],
689-
callback_url: str | None,
690-
code: str | None,
702+
completion: CapabilityAuthCompleteInput,
691703
account_hint: str | None,
692704
context: CapabilityCallContext,
693705
) -> CapabilityAuthCompleteResult:
@@ -698,8 +710,7 @@ async def _provider_auth_complete(
698710
return await provider_impl.auth_complete(
699711
capability_id=capability_id,
700712
flow_state=flow_state,
701-
callback_url=callback_url,
702-
code=code,
713+
completion=completion,
703714
context=context,
704715
)
705716

src/ash/capabilities/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from ash.capabilities.providers.base import (
44
CapabilityAuthBeginResult,
5+
CapabilityAuthCompleteInput,
56
CapabilityAuthCompleteResult,
67
CapabilityAuthPollResult,
78
CapabilityCallContext,
@@ -11,6 +12,7 @@
1112

1213
__all__ = [
1314
"CapabilityAuthBeginResult",
15+
"CapabilityAuthCompleteInput",
1416
"CapabilityAuthCompleteResult",
1517
"CapabilityAuthPollResult",
1618
"CapabilityCallContext",

src/ash/capabilities/providers/base.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class CapabilityAuthBeginResult:
3636
flow_type: str = "authorization_code" # or "device_code"
3737
user_code: str | None = None
3838
poll_interval_seconds: int | None = None
39+
expected_callback_state: str | None = None
3940

4041

4142
@dataclass(slots=True)
@@ -47,6 +48,15 @@ class CapabilityAuthCompleteResult:
4748
metadata: dict[str, Any] = field(default_factory=dict)
4849

4950

51+
@dataclass(slots=True)
52+
class CapabilityAuthCompleteInput:
53+
"""Normalized auth completion input for providers."""
54+
55+
authorization_code: str
56+
raw_callback_url: str | None = None
57+
state: str | None = None
58+
59+
5060
@dataclass(slots=True)
5161
class CapabilityAuthPollResult:
5262
"""Provider response for device code auth polling."""
@@ -94,8 +104,7 @@ async def auth_complete(
94104
*,
95105
capability_id: str,
96106
flow_state: dict[str, Any],
97-
callback_url: str | None,
98-
code: str | None,
107+
completion: CapabilityAuthCompleteInput,
99108
context: CapabilityCallContext,
100109
) -> CapabilityAuthCompleteResult:
101110
"""Complete auth flow and return linked account result."""

src/ash/capabilities/providers/subprocess.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ash.capabilities.providers.base import (
2121
CapabilityAuthBeginResult,
22+
CapabilityAuthCompleteInput,
2223
CapabilityAuthCompleteResult,
2324
CapabilityAuthPollResult,
2425
CapabilityCallContext,
@@ -121,6 +122,9 @@ async def auth_begin(
121122
flow_type=flow_type,
122123
user_code=user_code,
123124
poll_interval_seconds=poll_interval,
125+
expected_callback_state=_optional_text(
126+
result.get("expected_callback_state")
127+
),
124128
)
125129

126130
async def auth_poll(
@@ -168,17 +172,17 @@ async def auth_complete(
168172
*,
169173
capability_id: str,
170174
flow_state: dict[str, Any],
171-
callback_url: str | None,
172-
code: str | None,
175+
completion: CapabilityAuthCompleteInput,
173176
context: CapabilityCallContext,
174177
) -> CapabilityAuthCompleteResult:
175178
result = await self._call_bridge(
176179
"auth_complete",
177180
{
178181
"capability_id": capability_id,
179182
"flow_state": dict(flow_state),
180-
"callback_url": callback_url,
181-
"code": code,
183+
"authorization_code": completion.authorization_code,
184+
"raw_callback_url": completion.raw_callback_url,
185+
"state": completion.state,
182186
"context_token": self._issue_context_token(context),
183187
},
184188
)
@@ -526,6 +530,13 @@ def _required_text(*, value: Any, code: str, message: str) -> str:
526530
return text
527531

528532

533+
def _optional_text(value: Any) -> str | None:
534+
if value is None:
535+
return None
536+
text = str(value).strip()
537+
return text or None
538+
539+
529540
def _capability_error(code: str, message: str) -> Exception:
530541
from ash.capabilities.manager import CapabilityError
531542

src/ash/capabilities/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class CapabilityAuthFlow:
4444
expires_at: datetime
4545
flow_state: dict[str, Any] = field(default_factory=dict)
4646
flow_type: str = "authorization_code"
47+
expected_callback_state: str | None = None
4748

4849

4950
@dataclass(slots=True)

src/ash/skills/bundled/gog/scripts/gogcli_bridge.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@ def _handle_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
739739
"expires_at": _iso8601_utc(expires_epoch),
740740
"flow_state": flow_state,
741741
"flow_type": "authorization_code",
742+
"expected_callback_state": state_param,
742743
}
743744

744745

@@ -986,9 +987,9 @@ def _handle_auth_complete(params: dict[str, Any]) -> dict[str, Any]:
986987
or "default"
987988
)
988989
code = _required_text(
989-
params.get("code"),
990+
params.get("authorization_code"),
990991
code="capability_invalid_input",
991-
message="code is required for auth_complete",
992+
message="authorization_code is required for auth_complete",
992993
)
993994
redirect_uri = (
994995
_optional_text(stored_flow.get("redirect_uri")) or AUTH_CODE_REDIRECT_URI

0 commit comments

Comments
 (0)