Skip to content

Commit 0f1049e

Browse files
dcramercodex
andcommitted
Harden auth code normalization for pasted callback/code variants
Normalize --code inputs that are actually callback URLs or query fragments, strip wrapper punctuation/whitespace, and preserve inferred callback metadata/state for provider completion. Also update Google skill command examples to quote callback/code arguments and add regression coverage for both callback-in-code and query-fragment inputs. Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent f97270b commit 0f1049e

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

src/ash/capabilities/auth_normalization.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,22 @@ def normalize_auth_completion(
3030
expected_state: str | None,
3131
) -> NormalizedAuthCompletion:
3232
"""Normalize callback URL / code inputs into one authorization code."""
33-
normalized_code = _optional_text(code)
33+
normalized_code: str | None = None
3434
normalized_callback_url = _optional_text(callback_url)
3535
callback_code: str | None = None
3636
callback_state: str | None = None
3737

38+
if code is not None:
39+
(
40+
normalized_code,
41+
inferred_callback_url,
42+
inferred_state,
43+
) = _normalize_code_input(code)
44+
if normalized_callback_url is None and inferred_callback_url is not None:
45+
normalized_callback_url = inferred_callback_url
46+
if callback_state is None and inferred_state is not None:
47+
callback_state = inferred_state
48+
3849
if normalized_callback_url is not None:
3950
callback_code, callback_state = _parse_callback_url(normalized_callback_url)
4051

@@ -96,3 +107,54 @@ def _optional_text(value: str | None) -> str | None:
96107
return None
97108
text = str(value).strip()
98109
return text or None
110+
111+
112+
def _normalize_code_input(code: str) -> tuple[str | None, str | None, str | None]:
113+
text = _optional_text(code)
114+
if text is None:
115+
return None, None, None
116+
117+
cleaned = _trim_wrapper_punctuation(text)
118+
compact = "".join(cleaned.split())
119+
120+
try:
121+
code_from_url, state_from_url = _parse_callback_url(compact)
122+
except AuthNormalizationError:
123+
pass
124+
else:
125+
return code_from_url, compact, state_from_url
126+
127+
code_from_query, state_from_query = _extract_code_from_query_fragment(compact)
128+
if code_from_query is not None:
129+
return code_from_query, None, state_from_query
130+
131+
return compact, None, None
132+
133+
134+
def _extract_code_from_query_fragment(text: str) -> tuple[str | None, str | None]:
135+
fragment = text
136+
if "?" in fragment:
137+
fragment = fragment.split("?", 1)[1]
138+
139+
if "code=" not in fragment:
140+
return None, None
141+
142+
query = parse_qs(fragment, keep_blank_values=True)
143+
code = _optional_text((query.get("code") or [None])[0])
144+
state = _optional_text((query.get("state") or [None])[0])
145+
return code, state
146+
147+
148+
def _trim_wrapper_punctuation(text: str) -> str:
149+
cleaned = text.strip()
150+
if len(cleaned) >= 2:
151+
pairs = (("(", ")"), ("[", "]"), ("{", "}"), ("<", ">"), ("`", "`"))
152+
changed = True
153+
while changed and len(cleaned) >= 2:
154+
changed = False
155+
for left, right in pairs:
156+
if cleaned.startswith(left) and cleaned.endswith(right):
157+
cleaned = cleaned[1:-1].strip()
158+
changed = True
159+
break
160+
return cleaned.rstrip(".,;")

src/ash/integrations/skills/capabilities/google/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Run this step for each capability where `Authenticated: no`. If the user's reque
6464
Before prompting the user again, check whether the current task already contains a pasted Google callback URL (`http://localhost/?...code=...`) or a raw auth code. If yes:
6565

6666
1. Run `ash-sb capability auth begin -c <capability>` first.
67-
2. Immediately run `ash-sb capability auth complete --flow-id <id> --callback-url <URL>` (or `--code <CODE>`).
67+
2. Immediately run `ash-sb capability auth complete --flow-id <id> --callback-url '<URL>'` (or `--code '<CODE>'`).
6868
3. Re-run `ash-sb capability list` and continue to operations if authenticated.
6969

7070
Do not ask the user for another URL/code when one is already present in the task.
@@ -98,7 +98,7 @@ Started capability auth flow (flow_id=abc123)
9898
Check the `Flow type` in the output:
9999

100100
- If `device_code`: show the `Auth URL` and `User code` from the output. Tell the user to open the URL and enter the code. Then proceed to step 2c to poll for completion.
101-
- If `authorization_code`: show the `Auth URL` from the output and ask the user to complete the Google consent screen and provide either the authorization code or the callback URL. Then use `ash-sb capability auth complete --flow-id <id> --code <CODE>` (or `--callback-url <URL>` when the user pastes the full callback URL). Do **not** pass `-c/--capability` to `auth complete`; that option is only valid for `auth begin`.
101+
- If `authorization_code`: show the `Auth URL` from the output and ask the user to complete the Google consent screen and provide either the authorization code or the callback URL. Then use `ash-sb capability auth complete --flow-id <id> --code '<CODE>'` (or `--callback-url '<URL>'` when the user pastes the full callback URL). Do **not** pass `-c/--capability` to `auth complete`; that option is only valid for `auth begin`.
102102

103103
**2c. Poll for completion (device code flow)**
104104

tests/test_capabilities.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,63 @@ async def test_auth_complete_rejects_callback_state_mismatch() -> None:
400400
assert exc_info.value.code == "capability_auth_state_mismatch"
401401

402402

403+
@pytest.mark.asyncio
404+
async def test_auth_complete_accepts_callback_url_in_code_field() -> None:
405+
manager = CapabilityManager(auth_flow_ttl_seconds=300)
406+
provider = _RecordingProvider(namespace="gog")
407+
await manager.register_provider(provider)
408+
begin = await manager.auth_begin(
409+
capability_id="gog.email",
410+
user_id="user-1",
411+
chat_type="private",
412+
account_hint="work",
413+
)
414+
415+
flow_id = str(begin["flow_id"])
416+
manager._auth_flows[flow_id].expected_callback_state = "expected-state"
417+
callback = "http://localhost/?state=expected-state&code=abc123&scope=mail"
418+
419+
result = await manager.auth_complete(
420+
flow_id=flow_id,
421+
user_id="user-1",
422+
callback_url=None,
423+
code=callback,
424+
)
425+
426+
assert result["ok"] is True
427+
completion = provider.complete_calls[0]["completion"]
428+
assert completion.authorization_code == "abc123"
429+
assert completion.raw_callback_url == callback
430+
431+
432+
@pytest.mark.asyncio
433+
async def test_auth_complete_accepts_code_query_fragment_in_code_field() -> None:
434+
manager = CapabilityManager(auth_flow_ttl_seconds=300)
435+
provider = _RecordingProvider(namespace="gog")
436+
await manager.register_provider(provider)
437+
begin = await manager.auth_begin(
438+
capability_id="gog.email",
439+
user_id="user-1",
440+
chat_type="private",
441+
account_hint="work",
442+
)
443+
444+
flow_id = str(begin["flow_id"])
445+
manager._auth_flows[flow_id].expected_callback_state = "expected-state"
446+
447+
result = await manager.auth_complete(
448+
flow_id=flow_id,
449+
user_id="user-1",
450+
callback_url=None,
451+
code="?state=expected-state&code=abc123",
452+
)
453+
454+
assert result["ok"] is True
455+
completion = provider.complete_calls[0]["completion"]
456+
assert completion.authorization_code == "abc123"
457+
assert completion.state == "expected-state"
458+
459+
403460
@pytest.mark.asyncio
404461
async def test_auth_begin_reuses_pending_flow_for_same_scope() -> None:
405462
manager = CapabilityManager(auth_flow_ttl_seconds=300)

0 commit comments

Comments
 (0)