Skip to content

Commit 05f81a5

Browse files
committed
add oauth helpers
1 parent dede1d2 commit 05f81a5

File tree

5 files changed

+316
-0
lines changed

5 files changed

+316
-0
lines changed

.claude/settings.local.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(python3:*)"
5+
],
6+
"deny": [],
7+
"ask": []
8+
}
9+
}

examples/oauth_pkce_example.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
Example of using OAuth PKCE helper functions
3+
4+
This example demonstrates how to:
5+
1. Generate a SHA-256 code challenge and verifier
6+
2. Create an authorization URL for OAuth flow
7+
"""
8+
9+
from openrouter import OpenRouter
10+
from openrouter.utils import (
11+
oauth_create_sha256_code_challenge,
12+
oauth_create_authorization_url,
13+
CreateSHA256CodeChallengeRequest,
14+
CreateAuthorizationUrlRequestWithPKCE,
15+
)
16+
17+
18+
def main():
19+
# Step 1: Generate a code challenge and verifier
20+
# You can optionally provide your own code_verifier, or let it generate one
21+
result = oauth_create_sha256_code_challenge()
22+
23+
print("Code Challenge:", result.code_challenge)
24+
print("Code Verifier:", result.code_verifier)
25+
print()
26+
27+
# Or provide your own code verifier (must be 43-128 chars, [A-Za-z0-9-._~])
28+
custom_result = oauth_create_sha256_code_challenge(
29+
CreateSHA256CodeChallengeRequest(
30+
code_verifier="my-custom-verifier-that-is-at-least-43-characters-long-abcdefghij"
31+
)
32+
)
33+
print("Custom Code Challenge:", custom_result.code_challenge)
34+
print("Custom Code Verifier:", custom_result.code_verifier)
35+
print()
36+
37+
# Step 2: Create an authorization URL
38+
client = OpenRouter(api_key="your-api-key")
39+
40+
# Create authorization URL with PKCE
41+
auth_url = oauth_create_authorization_url(
42+
client,
43+
CreateAuthorizationUrlRequestWithPKCE(
44+
callback_url="https://your-app.com/callback",
45+
code_challenge=result.code_challenge,
46+
code_challenge_method="S256",
47+
limit=10.0, # Optional credit limit
48+
)
49+
)
50+
51+
print("Authorization URL:", auth_url)
52+
print()
53+
54+
# Step 3: User would visit the authorization URL and authorize the app
55+
# Step 4: After authorization, the callback URL receives an authorization code
56+
# Step 5: Exchange the code for an API key using the SDK's exchange method
57+
# code = "authorization-code-from-callback"
58+
# api_key_response = client.o_auth.exchange_auth_code_for_api_key(
59+
# code=code,
60+
# code_verifier=result.code_verifier,
61+
# code_challenge_method="S256",
62+
# )
63+
# print("API Key:", api_key_response.key)
64+
65+
66+
if __name__ == "__main__":
67+
main()

src/openrouter/utils/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,25 @@
5353
cast_partial,
5454
)
5555
from .logger import Logger, get_body_content, get_default_logger
56+
from .oauth_create_sha256_code_challenge import (
57+
oauth_create_sha256_code_challenge,
58+
CreateSHA256CodeChallengeRequest,
59+
CreateSHA256CodeChallengeResponse,
60+
)
61+
from .oauth_create_authorization_url import (
62+
oauth_create_authorization_url,
63+
CreateAuthorizationUrlRequest,
64+
CreateAuthorizationUrlRequestBase,
65+
CreateAuthorizationUrlRequestWithPKCE,
66+
)
5667

5768
__all__ = [
5869
"BackoffStrategy",
70+
"CreateAuthorizationUrlRequest",
71+
"CreateAuthorizationUrlRequestBase",
72+
"CreateAuthorizationUrlRequestWithPKCE",
73+
"CreateSHA256CodeChallengeRequest",
74+
"CreateSHA256CodeChallengeResponse",
5975
"FieldMetadata",
6076
"find_metadata",
6177
"FormMetadata",
@@ -78,6 +94,8 @@
7894
"match_status_codes",
7995
"match_response",
8096
"MultipartFormMetadata",
97+
"oauth_create_authorization_url",
98+
"oauth_create_sha256_code_challenge",
8199
"OpenEnumMeta",
82100
"PathParamMetadata",
83101
"QueryParamMetadata",
@@ -110,6 +128,11 @@
110128

111129
_dynamic_imports: dict[str, str] = {
112130
"BackoffStrategy": ".retries",
131+
"CreateAuthorizationUrlRequest": ".oauth_create_authorization_url",
132+
"CreateAuthorizationUrlRequestBase": ".oauth_create_authorization_url",
133+
"CreateAuthorizationUrlRequestWithPKCE": ".oauth_create_authorization_url",
134+
"CreateSHA256CodeChallengeRequest": ".oauth_create_sha256_code_challenge",
135+
"CreateSHA256CodeChallengeResponse": ".oauth_create_sha256_code_challenge",
113136
"FieldMetadata": ".metadata",
114137
"find_metadata": ".metadata",
115138
"FormMetadata": ".metadata",
@@ -132,6 +155,8 @@
132155
"match_status_codes": ".values",
133156
"match_response": ".values",
134157
"MultipartFormMetadata": ".metadata",
158+
"oauth_create_authorization_url": ".oauth_create_authorization_url",
159+
"oauth_create_sha256_code_challenge": ".oauth_create_sha256_code_challenge",
135160
"OpenEnumMeta": ".enums",
136161
"PathParamMetadata": ".metadata",
137162
"QueryParamMetadata": ".metadata",
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Generate OAuth2 authorization URL"""
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING, Literal, Optional, Union
5+
from urllib.parse import urlencode, urlparse
6+
7+
if TYPE_CHECKING:
8+
from openrouter.sdk import OpenRouter
9+
10+
11+
@dataclass
12+
class CreateAuthorizationUrlRequestBase:
13+
"""Base request parameters for creating an authorization URL"""
14+
callback_url: Union[str, "urlparse"]
15+
limit: Optional[float] = None
16+
17+
18+
@dataclass
19+
class CreateAuthorizationUrlRequestWithPKCE(CreateAuthorizationUrlRequestBase):
20+
"""Request parameters with PKCE for creating an authorization URL"""
21+
code_challenge_method: Literal["S256", "plain"]
22+
code_challenge: str
23+
24+
25+
# Union type for request - either with PKCE or without
26+
CreateAuthorizationUrlRequest = Union[
27+
CreateAuthorizationUrlRequestWithPKCE,
28+
CreateAuthorizationUrlRequestBase,
29+
]
30+
31+
32+
def _get_server_url(client: "OpenRouter") -> str:
33+
"""
34+
Get the server URL from the client configuration
35+
36+
Args:
37+
client: OpenRouter client instance
38+
39+
Returns:
40+
The server URL
41+
42+
Raises:
43+
ValueError: If no server URL is configured
44+
"""
45+
server_url, _ = client.sdk_configuration.get_server_details()
46+
if not server_url:
47+
raise ValueError("No server URL configured")
48+
return server_url
49+
50+
51+
def oauth_create_authorization_url(
52+
client: "OpenRouter",
53+
params: CreateAuthorizationUrlRequest,
54+
) -> str:
55+
"""
56+
Generate an OAuth2 authorization URL
57+
58+
Generates a URL to redirect users to for authorizing your application. The
59+
URL includes the provided callback URL and, if applicable, the code
60+
challenge parameters for PKCE.
61+
62+
Args:
63+
client: OpenRouter client instance
64+
params: Request parameters including callback URL and optional PKCE parameters
65+
66+
Returns:
67+
The authorization URL as a string
68+
69+
Raises:
70+
ValueError: If no server URL is configured or parameters are invalid
71+
72+
See Also:
73+
- https://openrouter.ai/docs/use-cases/oauth-pkce
74+
"""
75+
base_url = _get_server_url(client)
76+
77+
# Build the auth URL
78+
auth_url = f"{base_url}/auth"
79+
80+
# Build query parameters
81+
query_params = {
82+
"callback_url": str(params.callback_url),
83+
}
84+
85+
# Add PKCE parameters if present
86+
if isinstance(params, CreateAuthorizationUrlRequestWithPKCE):
87+
query_params["code_challenge"] = params.code_challenge
88+
query_params["code_challenge_method"] = params.code_challenge_method
89+
90+
# Add limit if present
91+
if params.limit is not None:
92+
query_params["limit"] = str(params.limit)
93+
94+
# Construct final URL with query parameters
95+
return f"{auth_url}?{urlencode(query_params)}"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Generate SHA-256 code challenge for PKCE OAuth flow"""
2+
3+
import base64
4+
import hashlib
5+
import re
6+
import secrets
7+
from dataclasses import dataclass
8+
from typing import Optional
9+
10+
11+
@dataclass
12+
class CreateSHA256CodeChallengeRequest:
13+
"""
14+
Request parameters for creating a SHA-256 code challenge.
15+
16+
If not provided, a random code verifier will be generated.
17+
If provided, must be 43-128 characters and contain only unreserved
18+
characters [A-Za-z0-9-._~] per RFC 7636.
19+
"""
20+
code_verifier: Optional[str] = None
21+
22+
23+
@dataclass
24+
class CreateSHA256CodeChallengeResponse:
25+
"""Response containing the code challenge and verifier"""
26+
code_challenge: str
27+
code_verifier: str
28+
29+
30+
def _array_buffer_to_base64_url(data: bytes) -> str:
31+
"""
32+
Convert bytes to base64url encoding (RFC 4648)
33+
34+
Args:
35+
data: Bytes to encode
36+
37+
Returns:
38+
Base64url encoded string
39+
"""
40+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
41+
42+
43+
def _generate_code_verifier() -> str:
44+
"""
45+
Generate a cryptographically random code verifier per RFC 7636
46+
47+
RFC 7636 recommends 32 octets of random data, base64url encoded = 43 chars
48+
49+
Returns:
50+
A random code verifier string
51+
"""
52+
random_bytes = secrets.token_bytes(32)
53+
return _array_buffer_to_base64_url(random_bytes)
54+
55+
56+
def _validate_code_verifier(code_verifier: str) -> None:
57+
"""
58+
Validate code verifier according to RFC 7636
59+
60+
Args:
61+
code_verifier: The code verifier to validate
62+
63+
Raises:
64+
ValueError: If the code verifier is invalid
65+
"""
66+
if len(code_verifier) < 43:
67+
raise ValueError("Code verifier must be at least 43 characters")
68+
if len(code_verifier) > 128:
69+
raise ValueError("Code verifier must be at most 128 characters")
70+
if not re.match(r"^[A-Za-z0-9\-._~]+$", code_verifier):
71+
raise ValueError(
72+
"Code verifier must only contain unreserved characters: [A-Za-z0-9-._~]"
73+
)
74+
75+
76+
def oauth_create_sha256_code_challenge(
77+
params: Optional[CreateSHA256CodeChallengeRequest] = None,
78+
) -> CreateSHA256CodeChallengeResponse:
79+
"""
80+
Generate a SHA-256 code challenge for PKCE
81+
82+
Generates a SHA-256 code challenge and corresponding code verifier for use
83+
in the PKCE extension to OAuth2. If no code verifier is provided, a random
84+
one will be generated according to RFC 7636 (32 random bytes, base64url
85+
encoded). If a code verifier is provided, it must be 43-128 characters and
86+
contain only unreserved characters [A-Za-z0-9-._~].
87+
88+
Args:
89+
params: Optional request parameters. If None, a random code verifier will be generated.
90+
91+
Returns:
92+
CreateSHA256CodeChallengeResponse containing the code challenge and verifier
93+
94+
Raises:
95+
ValueError: If the provided code verifier is invalid
96+
97+
See Also:
98+
- https://openrouter.ai/docs/use-cases/oauth-pkce
99+
- https://datatracker.ietf.org/doc/html/rfc7636
100+
"""
101+
if params is None:
102+
params = CreateSHA256CodeChallengeRequest()
103+
104+
code_verifier = params.code_verifier
105+
if code_verifier is None:
106+
code_verifier = _generate_code_verifier()
107+
else:
108+
_validate_code_verifier(code_verifier)
109+
110+
# Generate SHA-256 hash
111+
data = code_verifier.encode("utf-8")
112+
hash_digest = hashlib.sha256(data).digest()
113+
114+
# Convert hash to base64url
115+
code_challenge = _array_buffer_to_base64_url(hash_digest)
116+
117+
return CreateSHA256CodeChallengeResponse(
118+
code_challenge=code_challenge,
119+
code_verifier=code_verifier,
120+
)

0 commit comments

Comments
 (0)