Skip to content

Commit 19b8ffc

Browse files
feat: add OAuth PKCE helper functions and example usage
1 parent cb401a8 commit 19b8ffc

4 files changed

Lines changed: 265 additions & 2 deletions

File tree

.genignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
pylintrc
22
docs/docs.json
33
docs/overview.mdx
4+
examples
5+
src/openrouter/pkce.py

examples/oauth_pkce_example.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
This example demonstrates how to:
55
1. Generate a SHA-256 code challenge and verifier
66
2. Create an authorization URL for OAuth flow
7+
8+
Run with: uv run python examples/oauth_pkce_example.py
79
"""
810

911
from openrouter import OpenRouter
10-
from openrouter.utils import (
12+
from openrouter.pkce import (
1113
oauth_create_sha256_code_challenge,
1214
oauth_create_authorization_url,
1315
CreateSHA256CodeChallengeRequest,
@@ -45,7 +47,7 @@ def main():
4547
code_challenge=result.code_challenge,
4648
code_challenge_method="S256",
4749
limit=10.0, # Optional credit limit
48-
)
50+
),
4951
)
5052

5153
print("Authorization URL:", auth_url)

src/openrouter/pkce.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""OAuth PKCE helpers.
2+
3+
Hand-written, not generated. Kept out of the generated tree and listed in
4+
`.genignore` so a regeneration cannot delete it — that is exactly how the
5+
previous version of these helpers was lost (added in 05f81a5, removed as
6+
collateral damage by the regen in e6b0242, which left examples/ importing
7+
symbols that no longer existed).
8+
9+
See https://openrouter.ai/docs/use-cases/oauth-pkce and RFC 7636.
10+
"""
11+
12+
import base64
13+
import hashlib
14+
import re
15+
import secrets
16+
from dataclasses import dataclass
17+
from typing import TYPE_CHECKING, Literal, Optional, Union
18+
from urllib.parse import ParseResult, urlencode, urlsplit, urlunsplit
19+
20+
if TYPE_CHECKING:
21+
from openrouter.sdk import OpenRouter
22+
23+
24+
@dataclass
25+
class CreateSHA256CodeChallengeRequest:
26+
"""Parameters for creating a SHA-256 code challenge.
27+
28+
If `code_verifier` is omitted a random one is generated. If supplied it must
29+
be 43-128 characters of unreserved characters `[A-Za-z0-9-._~]` per RFC 7636.
30+
"""
31+
32+
code_verifier: Optional[str] = None
33+
34+
35+
@dataclass
36+
class CreateSHA256CodeChallengeResponse:
37+
"""The generated code challenge and the verifier it was derived from."""
38+
39+
code_challenge: str
40+
code_verifier: str
41+
42+
43+
@dataclass
44+
class CreateAuthorizationUrlRequestBase:
45+
"""Authorization URL parameters without PKCE."""
46+
47+
callback_url: Union[str, ParseResult]
48+
limit: Optional[float] = None
49+
50+
51+
@dataclass
52+
class CreateAuthorizationUrlRequestWithPKCE:
53+
"""Authorization URL parameters with PKCE."""
54+
55+
callback_url: Union[str, ParseResult]
56+
code_challenge_method: Literal["S256", "plain"]
57+
code_challenge: str
58+
limit: Optional[float] = None
59+
60+
61+
CreateAuthorizationUrlRequest = Union[
62+
CreateAuthorizationUrlRequestWithPKCE,
63+
CreateAuthorizationUrlRequestBase,
64+
]
65+
66+
67+
def _b64url(data: bytes) -> str:
68+
"""Base64url-encode without padding (RFC 4648 §5)."""
69+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
70+
71+
72+
def _generate_code_verifier() -> str:
73+
"""Generate a random code verifier: 32 octets base64url-encoded = 43 chars."""
74+
return _b64url(secrets.token_bytes(32))
75+
76+
77+
def _validate_code_verifier(code_verifier: str) -> None:
78+
"""Raise ValueError if `code_verifier` does not satisfy RFC 7636 §4.1."""
79+
if len(code_verifier) < 43:
80+
raise ValueError("Code verifier must be at least 43 characters")
81+
if len(code_verifier) > 128:
82+
raise ValueError("Code verifier must be at most 128 characters")
83+
if not re.match(r"^[A-Za-z0-9\-._~]+$", code_verifier):
84+
raise ValueError(
85+
"Code verifier must only contain unreserved characters: [A-Za-z0-9-._~]"
86+
)
87+
88+
89+
def _as_url(value: Union[str, ParseResult]) -> str:
90+
"""Render a URL. `str()` on a ParseResult yields its repr, not the URL."""
91+
return value.geturl() if isinstance(value, ParseResult) else value
92+
93+
94+
def _get_site_origin(client: "OpenRouter") -> str:
95+
"""Derive the site origin from the configured API server URL.
96+
97+
The authorization page lives on the site root (`https://openrouter.ai/auth`),
98+
not under the API base path — `https://openrouter.ai/api/v1/auth` is a 404.
99+
Deriving from the configured server URL keeps regional hosts such as
100+
`eu.openrouter.ai` and custom base URLs working.
101+
"""
102+
server_url, _ = client.sdk_configuration.get_server_details()
103+
if not server_url:
104+
raise ValueError("No server URL configured")
105+
106+
parts = urlsplit(server_url)
107+
if not parts.scheme or not parts.netloc:
108+
raise ValueError(f"Cannot derive an authorization URL from {server_url!r}")
109+
110+
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
111+
112+
113+
def oauth_create_sha256_code_challenge(
114+
params: Optional[CreateSHA256CodeChallengeRequest] = None,
115+
) -> CreateSHA256CodeChallengeResponse:
116+
"""Generate a SHA-256 code challenge and its code verifier for PKCE.
117+
118+
Args:
119+
params: Optional parameters. A random verifier is generated when omitted.
120+
121+
Returns:
122+
The code challenge and the verifier it was derived from. Keep the
123+
verifier; it is required to exchange the auth code for an API key.
124+
125+
Raises:
126+
ValueError: If a supplied code verifier is invalid.
127+
"""
128+
if params is None:
129+
params = CreateSHA256CodeChallengeRequest()
130+
131+
code_verifier = params.code_verifier
132+
if code_verifier is None:
133+
code_verifier = _generate_code_verifier()
134+
else:
135+
_validate_code_verifier(code_verifier)
136+
137+
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
138+
139+
return CreateSHA256CodeChallengeResponse(
140+
code_challenge=_b64url(digest),
141+
code_verifier=code_verifier,
142+
)
143+
144+
145+
def oauth_create_authorization_url(
146+
client: "OpenRouter",
147+
params: CreateAuthorizationUrlRequest,
148+
) -> str:
149+
"""Build the URL to redirect users to in order to authorize your app.
150+
151+
Args:
152+
client: An OpenRouter client; its server URL determines the host.
153+
params: Callback URL, optional credit limit, and optional PKCE challenge.
154+
155+
Returns:
156+
The authorization URL.
157+
158+
Raises:
159+
ValueError: If the client has no usable server URL configured.
160+
"""
161+
query = {"callback_url": _as_url(params.callback_url)}
162+
163+
if isinstance(params, CreateAuthorizationUrlRequestWithPKCE):
164+
query["code_challenge"] = params.code_challenge
165+
query["code_challenge_method"] = params.code_challenge_method
166+
167+
if params.limit is not None:
168+
query["limit"] = str(params.limit)
169+
170+
return f"{_get_site_origin(client)}/auth?{urlencode(query)}"

tests/test_pkce.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import base64
2+
import hashlib
3+
4+
import pytest
5+
6+
from openrouter import OpenRouter
7+
from openrouter.pkce import (
8+
CreateAuthorizationUrlRequestBase,
9+
CreateAuthorizationUrlRequestWithPKCE,
10+
CreateSHA256CodeChallengeRequest,
11+
oauth_create_authorization_url,
12+
oauth_create_sha256_code_challenge,
13+
)
14+
15+
16+
def test_generated_verifier_is_a_valid_challenge_pair():
17+
res = oauth_create_sha256_code_challenge()
18+
19+
assert len(res.code_verifier) == 43
20+
expected = base64.urlsafe_b64encode(
21+
hashlib.sha256(res.code_verifier.encode()).digest()
22+
).rstrip(b"=")
23+
assert res.code_challenge == expected.decode()
24+
assert "=" not in res.code_challenge
25+
26+
27+
def test_supplied_verifier_is_validated_per_rfc7636():
28+
ok = "a" * 43
29+
assert oauth_create_sha256_code_challenge(
30+
CreateSHA256CodeChallengeRequest(code_verifier=ok)
31+
).code_verifier == ok
32+
33+
for bad in ["a" * 42, "a" * 129, "a" * 42 + "!"]:
34+
with pytest.raises(ValueError):
35+
oauth_create_sha256_code_challenge(
36+
CreateSHA256CodeChallengeRequest(code_verifier=bad)
37+
)
38+
39+
40+
def test_authorization_url_points_at_the_site_root_not_the_api_base():
41+
# https://openrouter.ai/api/v1/auth is a 404; the auth page is on the origin.
42+
url = oauth_create_authorization_url(
43+
OpenRouter(api_key="x"),
44+
CreateAuthorizationUrlRequestBase(callback_url="https://app.example/cb"),
45+
)
46+
47+
assert url.startswith("https://openrouter.ai/auth?")
48+
assert "/api/v1/" not in url
49+
assert "callback_url=https%3A%2F%2Fapp.example%2Fcb" in url
50+
51+
52+
def test_authorization_url_honors_custom_server_url():
53+
url = oauth_create_authorization_url(
54+
OpenRouter(api_key="x", server_url="https://eu.openrouter.ai/api/v1"),
55+
CreateAuthorizationUrlRequestBase(callback_url="https://app.example/cb"),
56+
)
57+
58+
assert url.startswith("https://eu.openrouter.ai/auth?")
59+
60+
61+
def test_authorization_url_carries_pkce_and_limit():
62+
url = oauth_create_authorization_url(
63+
OpenRouter(api_key="x"),
64+
CreateAuthorizationUrlRequestWithPKCE(
65+
callback_url="https://app.example/cb",
66+
code_challenge="challenge",
67+
code_challenge_method="S256",
68+
limit=10.0,
69+
),
70+
)
71+
72+
assert "code_challenge=challenge" in url
73+
assert "code_challenge_method=S256" in url
74+
assert "limit=10.0" in url
75+
76+
77+
def test_parse_result_callback_url_renders_as_a_url():
78+
# str() on a ParseResult yields its repr, which would corrupt the query param.
79+
from urllib.parse import urlparse
80+
81+
url = oauth_create_authorization_url(
82+
OpenRouter(api_key="x"),
83+
CreateAuthorizationUrlRequestBase(
84+
callback_url=urlparse("https://app.example/cb")
85+
),
86+
)
87+
88+
assert "callback_url=https%3A%2F%2Fapp.example%2Fcb" in url
89+
assert "ParseResult" not in url

0 commit comments

Comments
 (0)