Skip to content

Commit 88b1b05

Browse files
committed
fix oauth exchange bug
1 parent f65eae5 commit 88b1b05

5 files changed

Lines changed: 168 additions & 9 deletions

File tree

‎src/feedback/api/routes.py‎

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

33
import hashlib
44
import json
5+
import logging
56
from typing import cast
67

78
from starlette.requests import Request
@@ -23,6 +24,9 @@
2324
from feedback.service.oauth_state import GrantError, StateError
2425
from feedback.service.runtime import FeedbackRuntime
2526

27+
oauth_logger = logging.getLogger("feedback.oauth")
28+
github_logger = logging.getLogger("feedback.github")
29+
2630

2731
async def homepage(request: Request) -> Response:
2832
callback = services(request).config.service.oauth_callback
@@ -114,6 +118,16 @@ async def oauth_exchange(request: Request) -> Response:
114118
except StateError:
115119
raise ApiError("invalid_oauth_state", "Authorization must be restarted.", 400) from None
116120
except OAuthError as exc:
121+
oauth_logger.warning(
122+
"GitHub OAuth exchange failed: reason=%s status=%s upstream_code=%s "
123+
"github_request_id=%s site=%s origin=%s",
124+
exc.reason,
125+
exc.status,
126+
exc.upstream_code,
127+
exc.request_id,
128+
site.id,
129+
origin,
130+
)
117131
status = 502 if exc.code == "oauth_exchange_ambiguous" else 400
118132
raise ApiError(exc.code, "Authorization must be restarted.", status) from exc
119133
if container.grants is None:
@@ -151,6 +165,15 @@ async def ensure_discussion(request: Request) -> Response:
151165
except DiscussionError as exc:
152166
raise ApiError("discussion_invalid", str(exc), 400) from exc
153167
except GitHubError as exc:
168+
github_logger.warning(
169+
"GitHub discussion request failed: code=%s status=%s github_request_id=%s "
170+
"site=%s origin=%s",
171+
exc.code,
172+
exc.status,
173+
exc.request_id,
174+
site.id,
175+
origin,
176+
)
154177
raise ApiError("github_unavailable", "GitHub is temporarily unavailable.", 502) from exc
155178
return cors(
156179
JSONResponse(

‎src/feedback/app.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
import os
45
import stat
56
import time
@@ -32,6 +33,8 @@
3233
from feedback.service.reaction_cache import ReactionRefresher
3334
from feedback.service.runtime import FeedbackRuntime
3435

36+
logger = logging.getLogger("feedback.runtime")
37+
3538

3639
def create_app(
3740
config: Config | None = None,
@@ -88,6 +91,11 @@ async def lifespan(application: Starlette) -> AsyncIterator[None]:
8891
discussions=resolved_discussions,
8992
)
9093
application.state.services = services
94+
logger.info(
95+
"Feedback service started: public_origin=%s sites=%s",
96+
loaded.service.public_origin,
97+
",".join(sorted(loaded.sites)),
98+
)
9199
try:
92100
yield
93101
finally:

‎src/feedback/protocol/github/oauth.py‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,21 @@
1111

1212

1313
class OAuthError(RuntimeError):
14-
def __init__(self, code: str) -> None:
14+
def __init__(
15+
self,
16+
code: str,
17+
*,
18+
reason: str,
19+
status: int | None = None,
20+
upstream_code: str | None = None,
21+
request_id: str | None = None,
22+
) -> None:
1523
super().__init__(code)
1624
self.code = code
25+
self.reason = reason
26+
self.status = status
27+
self.upstream_code = upstream_code
28+
self.request_id = request_id
1729

1830

1931
@dataclass(frozen=True, slots=True)
@@ -80,23 +92,51 @@ async def exchange(
8092
},
8193
)
8294
except httpx.TimeoutException as exc:
83-
raise OAuthError("oauth_exchange_ambiguous") from exc
84-
if response.status_code != 200:
85-
raise OAuthError("oauth_exchange_failed")
95+
raise OAuthError("oauth_exchange_ambiguous", reason="timeout") from exc
96+
except httpx.RequestError as exc:
97+
raise OAuthError("oauth_exchange_failed", reason="transport_error") from exc
8698
try:
8799
body = response.json()
88100
except ValueError as exc:
89-
raise OAuthError("oauth_exchange_failed") from exc
90-
if not isinstance(body, dict) or body.get("error"):
91-
raise OAuthError("oauth_exchange_failed")
101+
raise OAuthError(
102+
"oauth_exchange_failed",
103+
reason="invalid_json",
104+
status=response.status_code,
105+
request_id=response.headers.get("x-github-request-id"),
106+
) from exc
107+
if not isinstance(body, dict):
108+
raise OAuthError(
109+
"oauth_exchange_failed",
110+
reason="invalid_payload",
111+
status=response.status_code,
112+
request_id=response.headers.get("x-github-request-id"),
113+
)
114+
upstream_code = body.get("error")
115+
if response.status_code != 200 or isinstance(upstream_code, str):
116+
raise OAuthError(
117+
"oauth_exchange_failed",
118+
reason="upstream_rejected",
119+
status=response.status_code,
120+
upstream_code=upstream_code if isinstance(upstream_code, str) else None,
121+
request_id=response.headers.get("x-github-request-id"),
122+
)
92123
token = body.get("access_token")
93124
expires_in = body.get("expires_in")
125+
# GitHub Apps may disable expiring user access tokens. Such responses do
126+
# not include expires_in; keep our browser session bounded to eight hours.
127+
if expires_in is None:
128+
expires_in = 8 * 60 * 60
94129
if (
95130
not isinstance(token, str)
96131
or not token.startswith("ghu_")
97132
or isinstance(expires_in, bool)
98133
or not isinstance(expires_in, int)
99134
or not 1 <= expires_in <= 8 * 60 * 60
100135
):
101-
raise OAuthError("oauth_exchange_failed")
136+
raise OAuthError(
137+
"oauth_exchange_failed",
138+
reason="invalid_token_payload",
139+
status=response.status_code,
140+
request_id=response.headers.get("x-github-request-id"),
141+
)
102142
return AccessToken(token, int(self._clock()) + expires_in), verified_state

‎src/feedback/service/runtime.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import logging
45
from collections.abc import Callable
56
from dataclasses import dataclass, field
67

@@ -11,6 +12,8 @@
1112
from feedback.service.oauth_state import CreationGrantSigner
1213
from feedback.service.reaction_cache import ReactionRefresher
1314

15+
logger = logging.getLogger("feedback.runtime")
16+
1417

1518
@dataclass(slots=True)
1619
class FeedbackRuntime:
@@ -56,4 +59,10 @@ async def close(self) -> None:
5659

5760
def _consume_task(task: asyncio.Task[int]) -> None:
5861
if not task.cancelled():
59-
task.exception()
62+
error = task.exception()
63+
if error is not None:
64+
logger.warning(
65+
"Background reaction refresh failed: error_type=%s error=%s",
66+
type(error).__name__,
67+
error,
68+
)

‎tests/test_oauth.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from urllib.parse import parse_qs, urlsplit
22

33
import httpx
4+
import pytest
45
from starlette.testclient import TestClient
56

67
from feedback.app import create_app
@@ -58,6 +59,84 @@ def handler(request: httpx.Request) -> httpx.Response:
5859
assert exchanged["repository_id"] == ["R_repo"]
5960

6061

62+
def test_exchange_bounds_nonexpiring_github_token_to_eight_hours(config: Config) -> None:
63+
http = httpx.AsyncClient(
64+
transport=httpx.MockTransport(
65+
lambda _: httpx.Response(200, json={"access_token": "ghu_user"})
66+
)
67+
)
68+
oauth = OAuthClient(
69+
client_id="Iv1.client",
70+
client_secret="client-secret",
71+
callback_url=config.service.oauth_callback,
72+
signer=StateSigner(b"k" * 32, clock=lambda: 1_000),
73+
http=http,
74+
clock=lambda: 1_000,
75+
)
76+
grants = CreationGrantSigner(b"k" * 32, clock=lambda: 1_000)
77+
with TestClient(create_app(config, clock=lambda: 1_000, oauth=oauth, grants=grants)) as client:
78+
authorize = client.post(
79+
"/v1/sites/cpp-social/oauth/authorize",
80+
headers={"Origin": "https://cpp.social"},
81+
json={"challenge": pkce_challenge(VERIFIER), "nonce": NONCE},
82+
)
83+
exchange = client.post(
84+
"/v1/sites/cpp-social/oauth/exchange",
85+
headers={"Origin": "https://cpp.social"},
86+
json={
87+
"code": "temporary-code",
88+
"state": authorize.json()["state"],
89+
"verifier": VERIFIER,
90+
},
91+
)
92+
93+
assert exchange.status_code == 200
94+
assert exchange.json()["expires_at"] == 29_800
95+
96+
97+
def test_exchange_logs_safe_upstream_failure_details(
98+
config: Config, caplog: pytest.LogCaptureFixture
99+
) -> None:
100+
http = httpx.AsyncClient(
101+
transport=httpx.MockTransport(
102+
lambda _: httpx.Response(
103+
200,
104+
headers={"x-github-request-id": "request-123"},
105+
json={"error": "bad_verification_code", "error_description": "secret detail"},
106+
)
107+
)
108+
)
109+
oauth = OAuthClient(
110+
client_id="Iv1.client",
111+
client_secret="client-secret",
112+
callback_url=config.service.oauth_callback,
113+
signer=StateSigner(b"k" * 32, clock=lambda: 1_000),
114+
http=http,
115+
clock=lambda: 1_000,
116+
)
117+
grants = CreationGrantSigner(b"k" * 32, clock=lambda: 1_000)
118+
with TestClient(create_app(config, clock=lambda: 1_000, oauth=oauth, grants=grants)) as client:
119+
authorize = client.post(
120+
"/v1/sites/cpp-social/oauth/authorize",
121+
headers={"Origin": "https://cpp.social"},
122+
json={"challenge": pkce_challenge(VERIFIER), "nonce": NONCE},
123+
)
124+
response = client.post(
125+
"/v1/sites/cpp-social/oauth/exchange",
126+
headers={"Origin": "https://cpp.social"},
127+
json={
128+
"code": "temporary-code",
129+
"state": authorize.json()["state"],
130+
"verifier": VERIFIER,
131+
},
132+
)
133+
134+
assert response.status_code == 400
135+
assert "upstream_code=bad_verification_code" in caplog.text
136+
assert "github_request_id=request-123" in caplog.text
137+
assert "secret detail" not in caplog.text
138+
139+
61140
def test_oauth_rejects_origin_content_type_and_duplicate_fields(config: Config) -> None:
62141
oauth = OAuthClient(
63142
client_id="Iv1.client",

0 commit comments

Comments
 (0)