Skip to content

Commit aa8a3dd

Browse files
committed
feat(examples): oauth2
1 parent 94ff4cd commit aa8a3dd

4 files changed

Lines changed: 455 additions & 2 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,22 @@ reader_checkout = client.readers.create_checkout(
110110
print(f"Reader checkout created: {reader_checkout}")
111111
```
112112

113+
### OAuth2 Authorization Code Flow
114+
115+
Run a local browser-based example that exchanges an authorization code for an
116+
access token and then fetches merchant information through the SDK:
117+
118+
```sh
119+
pip install -e ".[examples]"
120+
CLIENT_ID=your_client_id \
121+
CLIENT_SECRET=your_client_secret \
122+
REDIRECT_URI=http://localhost:8080/callback \
123+
python examples/oauth2.py
124+
```
125+
126+
This example uses Authlib for the OAuth2 Authorization Code flow with PKCE and
127+
the Python standard library for the local callback server.
128+
113129
## Version support policy
114130

115131
`sumup-py` maintains compatibility with Python versions that are no pass their End of life support, see [Status of Python versions](https://devguide.python.org/versions/).

examples/oauth2.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""OAuth 2.0 Authorization Code flow with SumUp.
2+
3+
This example uses Authlib to handle the OAuth2 Authorization Code flow with
4+
PKCE and then uses the resulting access token with the SumUp SDK.
5+
6+
Run:
7+
CLIENT_ID=... CLIENT_SECRET=... REDIRECT_URI=http://localhost:8080/callback \
8+
python examples/oauth2.py
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from sumup import Sumup
14+
15+
import http.server
16+
import json
17+
import os
18+
import sys
19+
import threading
20+
import urllib.parse
21+
import webbrowser
22+
from pathlib import Path
23+
24+
from authlib.integrations.httpx_client import OAuth2Client
25+
26+
ROOT = Path(__file__).resolve().parents[1]
27+
if str(ROOT) not in sys.path:
28+
sys.path.insert(0, str(ROOT))
29+
30+
AUTHORIZATION_ENDPOINT = "https://api.sumup.com/authorize"
31+
TOKEN_ENDPOINT = "https://api.sumup.com/token"
32+
SCOPES = "email profile"
33+
34+
35+
def build_redirect_uri() -> str:
36+
return os.getenv("REDIRECT_URI", "http://localhost:8080/callback")
37+
38+
39+
def build_server_address(redirect_uri: str) -> tuple[str, int]:
40+
parsed = urllib.parse.urlparse(redirect_uri)
41+
host = parsed.hostname or "localhost"
42+
port = parsed.port or 8080
43+
return host, port
44+
45+
46+
def build_callback_path(redirect_uri: str) -> str:
47+
parsed = urllib.parse.urlparse(redirect_uri)
48+
return parsed.path or "/callback"
49+
50+
51+
class OAuth2Server(http.server.ThreadingHTTPServer):
52+
def __init__(
53+
self,
54+
server_address: tuple[str, int],
55+
handler_class: type[http.server.BaseHTTPRequestHandler],
56+
*,
57+
oauth_client: OAuth2Client,
58+
callback_path: str,
59+
) -> None:
60+
super().__init__(server_address, handler_class)
61+
self.oauth_client = oauth_client
62+
self.callback_path = callback_path
63+
self.state: str | None = None
64+
self.code_verifier: str | None = None
65+
self.done = threading.Event()
66+
67+
68+
class OAuth2Handler(http.server.BaseHTTPRequestHandler):
69+
server: OAuth2Server
70+
71+
def do_GET(self) -> None: # noqa: N802
72+
parsed = urllib.parse.urlparse(self.path)
73+
if parsed.path == "/":
74+
self.send_response(200)
75+
self.send_header("Content-Type", "text/html; charset=utf-8")
76+
self.end_headers()
77+
self.wfile.write(
78+
b"<h1>SumUp OAuth2 Example</h1>"
79+
b"<p>This example uses Authlib for the OAuth2 Authorization Code flow with PKCE.</p>"
80+
b'<p><a href="/login">Start OAuth2 Flow</a></p>'
81+
)
82+
return
83+
84+
if parsed.path == "/login":
85+
self.handle_login()
86+
return
87+
88+
if parsed.path == self.server.callback_path:
89+
self.handle_callback()
90+
return
91+
92+
self.send_error(404, "Not Found")
93+
94+
def handle_login(self) -> None:
95+
authorization_url, state = self.server.oauth_client.create_authorization_url(
96+
AUTHORIZATION_ENDPOINT,
97+
scope=SCOPES,
98+
)
99+
code_verifier = getattr(self.server.oauth_client, "code_verifier", None)
100+
if not isinstance(code_verifier, str) or not code_verifier:
101+
self.respond_with_error(500, "OAuth client did not generate a PKCE code verifier")
102+
self.server.done.set()
103+
return
104+
105+
self.server.state = state
106+
self.server.code_verifier = code_verifier
107+
108+
self.send_response(302)
109+
self.send_header("Location", authorization_url)
110+
self.end_headers()
111+
112+
def handle_callback(self) -> None:
113+
parsed = urllib.parse.urlparse(self.path)
114+
params = urllib.parse.parse_qs(parsed.query)
115+
state = params.get("state", [""])[0]
116+
merchant_code = params.get("merchant_code", [""])[0]
117+
error = params.get("error", [""])[0]
118+
error_description = params.get("error_description", [""])[0]
119+
120+
if error:
121+
self.respond_with_error(400, f"OAuth error: {error_description or error}")
122+
self.server.done.set()
123+
return
124+
125+
if not self.server.state or state != self.server.state:
126+
self.respond_with_error(400, "Invalid OAuth state parameter")
127+
self.server.done.set()
128+
return
129+
130+
if not merchant_code:
131+
self.respond_with_error(
132+
400,
133+
"Missing merchant_code query parameter in callback response",
134+
)
135+
self.server.done.set()
136+
return
137+
138+
try:
139+
token = self.server.oauth_client.fetch_token(
140+
TOKEN_ENDPOINT,
141+
authorization_response=self.request_url(),
142+
code_verifier=self.server.code_verifier,
143+
)
144+
access_token = token["access_token"]
145+
client = Sumup(api_key=access_token)
146+
merchant = client.merchants.get(merchant_code)
147+
except Exception as exc: # noqa: BLE001
148+
self.respond_with_error(500, f"OAuth callback failed: {exc}")
149+
self.server.done.set()
150+
return
151+
152+
merchant_payload = merchant.model_dump() if hasattr(merchant, "model_dump") else merchant
153+
154+
self.send_response(200)
155+
self.send_header("Content-Type", "application/json; charset=utf-8")
156+
self.end_headers()
157+
self.wfile.write(
158+
json.dumps(
159+
{
160+
"merchant_code": merchant_code,
161+
"merchant": merchant_payload,
162+
},
163+
indent=2,
164+
default=str,
165+
).encode("utf-8")
166+
)
167+
168+
print(f"Merchant code: {merchant_code}")
169+
print(json.dumps(merchant_payload, indent=2, default=str))
170+
self.server.done.set()
171+
172+
def request_url(self) -> str:
173+
host = self.headers.get("Host", "localhost:8080")
174+
return f"http://{host}{self.path}"
175+
176+
def respond_with_error(self, status: int, message: str) -> None:
177+
self.send_response(status)
178+
self.send_header("Content-Type", "text/plain; charset=utf-8")
179+
self.end_headers()
180+
self.wfile.write(message.encode("utf-8"))
181+
182+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
183+
return
184+
185+
186+
def main() -> None:
187+
client_id = os.environ.get("CLIENT_ID")
188+
client_secret = os.environ.get("CLIENT_SECRET")
189+
redirect_uri = build_redirect_uri()
190+
191+
if not client_id or not client_secret:
192+
raise SystemExit("Please set CLIENT_ID and CLIENT_SECRET environment variables.")
193+
194+
oauth_client = OAuth2Client(
195+
client_id=client_id,
196+
client_secret=client_secret,
197+
redirect_uri=redirect_uri,
198+
code_challenge_method="S256",
199+
)
200+
201+
server_address = build_server_address(redirect_uri)
202+
callback_path = build_callback_path(redirect_uri)
203+
204+
server = OAuth2Server(
205+
server_address,
206+
OAuth2Handler,
207+
oauth_client=oauth_client,
208+
callback_path=callback_path,
209+
)
210+
211+
print(f"Server is running at http://{server_address[0]}:{server_address[1]}")
212+
print("Open /login to start the OAuth2 flow.")
213+
214+
thread = threading.Thread(target=server.serve_forever, daemon=True)
215+
thread.start()
216+
217+
login_url = f"http://{server_address[0]}:{server_address[1]}/login"
218+
webbrowser.open(login_url)
219+
220+
try:
221+
server.done.wait()
222+
except KeyboardInterrupt:
223+
pass
224+
finally:
225+
server.shutdown()
226+
server.server_close()
227+
oauth_client.close()
228+
229+
230+
if __name__ == "__main__":
231+
main()

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,20 @@ classifiers = [
3737
test = [
3838
"pytest>=8.3.2",
3939
]
40+
examples = [
41+
"authlib>=1.3.0",
42+
]
4043

4144
[dependency-groups]
4245
test = ["pytest>=8.3.2"]
4346
lint = ["ruff>=0.11.5"]
4447
typecheck = ["ty>=0.0.8"]
48+
examples = ["authlib>=1.3.0"]
4549
dev = [
4650
{include-group = "test"},
4751
{include-group = "lint"},
48-
{include-group = "typecheck"}
52+
{include-group = "typecheck"},
53+
{include-group = "examples"}
4954
]
5055

5156
[project.urls]

0 commit comments

Comments
 (0)