pyyapper 1.4.0__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyyapper-1.4.0/src/pyyapper.egg-info → pyyapper-1.5.0}/PKG-INFO +1 -1
- {pyyapper-1.4.0 → pyyapper-1.5.0}/pyproject.toml +1 -1
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/__init__.py +3 -0
- pyyapper-1.5.0/src/pyyapper/_version.py +1 -0
- pyyapper-1.5.0/src/pyyapper/oauth.py +217 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0/src/pyyapper.egg-info}/PKG-INFO +1 -1
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper.egg-info/SOURCES.txt +2 -0
- pyyapper-1.5.0/tests/test_oauth.py +35 -0
- pyyapper-1.4.0/src/pyyapper/_version.py +0 -1
- {pyyapper-1.4.0 → pyyapper-1.5.0}/LICENSE +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/README.md +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/setup.cfg +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/bot.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/buttons.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/cache.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/commands.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/config.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/embeds.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/errors.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/__init__.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/commands/__init__.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/commands/checks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/commands/converters.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/commands/core.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/commands/errors.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/ext/tasks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/http.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/i18n.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/metrics.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/models.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/pagination.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/presence.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/py.typed +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/wait_for.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper/webhooks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper.egg-info/dependency_links.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper.egg-info/requires.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/src/pyyapper.egg-info/top_level.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/tests/test_ext_commands.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/tests/test_smoke.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/tests/test_tasks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.5.0}/tests/test_webhooks.py +0 -0
|
@@ -23,6 +23,7 @@ from .presence import Activity, Presence
|
|
|
23
23
|
from .pagination import Paginator
|
|
24
24
|
from .webhooks import Webhook
|
|
25
25
|
from .wait_for import WaiterRegistry, wait_for
|
|
26
|
+
from .oauth import OAuth2Client, OAuthError, generate_state, generate_pkce, YapperOAuth, LoginWithYapper
|
|
26
27
|
from .config import load_config, load_env_file, get_token
|
|
27
28
|
from .i18n import Translator, t as translate
|
|
28
29
|
from .metrics import Metrics
|
|
@@ -38,6 +39,8 @@ __all__ = [
|
|
|
38
39
|
"User", "Server", "Channel", "Category", "Member", "Message", "Role", "VoiceChannel", "picture_url",
|
|
39
40
|
"Activity", "Presence",
|
|
40
41
|
"Paginator", "Webhook", "WaiterRegistry", "wait_for",
|
|
42
|
+
"OAuth2Client", "OAuthError", "generate_state", "generate_pkce",
|
|
43
|
+
"YapperOAuth", "LoginWithYapper",
|
|
41
44
|
"load_config", "load_env_file", "get_token",
|
|
42
45
|
"Translator", "translate", "Metrics",
|
|
43
46
|
"commands", "tasks",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.5.0"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""OAuth2 "Login with Yapper" client for PyYapper.
|
|
2
|
+
|
|
3
|
+
Implements the authorization-code flow (with optional PKCE) described in the
|
|
4
|
+
Yapper developer docs. This is intended for web apps that let users log in with
|
|
5
|
+
their Yapper account; it is independent of the bot API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
|
|
20
|
+
from .errors import YapperError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OAuthError(YapperError):
|
|
24
|
+
"""An OAuth2 request failed.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
error: OAuth2 error code (e.g. ``invalid_grant``).
|
|
28
|
+
description: Human readable description.
|
|
29
|
+
status: HTTP status code.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, error: str, description: str = "", *, status: int = 400) -> None:
|
|
33
|
+
self.error = error
|
|
34
|
+
self.description = description
|
|
35
|
+
self.status = status
|
|
36
|
+
super().__init__(f"{error}: {description}".strip(": "))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE = "https://www.yapper.website"
|
|
40
|
+
AUTHORIZE_PATH = "/oauth/authorize"
|
|
41
|
+
TOKEN_PATH = "/api/oauth/token"
|
|
42
|
+
ME_PATH = "/api/oauth/me"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _b64url(data: bytes) -> str:
|
|
46
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_state(n: int = 32) -> str:
|
|
50
|
+
"""Generate a random, URL-safe ``state`` value."""
|
|
51
|
+
return secrets.token_urlsafe(n)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_pkce(verifier_length: int = 64) -> tuple[str, str]:
|
|
55
|
+
"""Generate a (code_verifier, code_challenge) pair for PKCE (S256)."""
|
|
56
|
+
verifier = secrets.token_urlsafe(verifier_length)[:128]
|
|
57
|
+
if len(verifier) < 43:
|
|
58
|
+
verifier = verifier + secrets.token_urlsafe(43 - len(verifier))
|
|
59
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
60
|
+
return verifier, challenge
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OAuth2Client:
|
|
64
|
+
"""A client for one Yapper OAuth2 app.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
client_id:
|
|
69
|
+
The app's client id.
|
|
70
|
+
client_secret:
|
|
71
|
+
The app's client secret (omit for PKCE/public apps).
|
|
72
|
+
redirect_uri:
|
|
73
|
+
Default redirect URI (must match one registered for the app).
|
|
74
|
+
base_url:
|
|
75
|
+
Yapper base URL. Defaults to ``https://www.yapper.website``.
|
|
76
|
+
session:
|
|
77
|
+
Optional shared :class:`aiohttp.ClientSession`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
client_id: str,
|
|
83
|
+
client_secret: Optional[str] = None,
|
|
84
|
+
redirect_uri: Optional[str] = None,
|
|
85
|
+
*,
|
|
86
|
+
base_url: str = DEFAULT_BASE,
|
|
87
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.client_id = str(client_id)
|
|
90
|
+
self.client_secret = client_secret
|
|
91
|
+
self.redirect_uri = redirect_uri
|
|
92
|
+
self.base_url = base_url.rstrip("/")
|
|
93
|
+
self._session = session
|
|
94
|
+
self._owns_session = session is None
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ #
|
|
97
|
+
# Session management
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
100
|
+
if self._session is None or getattr(self._session, "closed", False):
|
|
101
|
+
self._session = aiohttp.ClientSession()
|
|
102
|
+
self._owns_session = True
|
|
103
|
+
return self._session
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
if self._owns_session and self._session is not None and not getattr(self._session, "closed", True):
|
|
107
|
+
await self._session.close()
|
|
108
|
+
self._session = None
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self) -> "OAuth2Client":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
114
|
+
await self.close()
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
# Authorization URL
|
|
118
|
+
# ------------------------------------------------------------------ #
|
|
119
|
+
def authorization_url(
|
|
120
|
+
self,
|
|
121
|
+
state: str,
|
|
122
|
+
*,
|
|
123
|
+
scope: str = "identify",
|
|
124
|
+
redirect_uri: Optional[str] = None,
|
|
125
|
+
code_challenge: Optional[str] = None,
|
|
126
|
+
code_challenge_method: str = "S256",
|
|
127
|
+
) -> str:
|
|
128
|
+
params: dict[str, Any] = {
|
|
129
|
+
"client_id": self.client_id,
|
|
130
|
+
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
131
|
+
"response_type": "code",
|
|
132
|
+
"scope": scope,
|
|
133
|
+
"state": state,
|
|
134
|
+
}
|
|
135
|
+
if code_challenge is not None:
|
|
136
|
+
params["code_challenge"] = code_challenge
|
|
137
|
+
params["code_challenge_method"] = code_challenge_method
|
|
138
|
+
return f"{self.base_url}{AUTHORIZE_PATH}?{urllib.parse.urlencode(params)}"
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ #
|
|
141
|
+
# Token exchange
|
|
142
|
+
# ------------------------------------------------------------------ #
|
|
143
|
+
async def exchange_code(
|
|
144
|
+
self,
|
|
145
|
+
code: str,
|
|
146
|
+
*,
|
|
147
|
+
redirect_uri: Optional[str] = None,
|
|
148
|
+
code_verifier: Optional[str] = None,
|
|
149
|
+
use_basic_auth: bool = False,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
url = f"{self.base_url}{TOKEN_PATH}"
|
|
152
|
+
body: dict[str, Any] = {
|
|
153
|
+
"grant_type": "authorization_code",
|
|
154
|
+
"code": code,
|
|
155
|
+
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
156
|
+
"client_id": self.client_id,
|
|
157
|
+
}
|
|
158
|
+
if self.client_secret is not None and not use_basic_auth:
|
|
159
|
+
body["client_secret"] = self.client_secret
|
|
160
|
+
if code_verifier is not None:
|
|
161
|
+
body["code_verifier"] = code_verifier
|
|
162
|
+
|
|
163
|
+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
164
|
+
if use_basic_auth and self.client_secret is not None:
|
|
165
|
+
import base64 as _b64
|
|
166
|
+
|
|
167
|
+
creds = _b64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
|
|
168
|
+
headers["Authorization"] = f"Basic {creds}"
|
|
169
|
+
body.pop("client_secret", None)
|
|
170
|
+
|
|
171
|
+
session = await self._get_session()
|
|
172
|
+
async with session.post(url, data=body, headers=headers) as res:
|
|
173
|
+
raw = await res.read()
|
|
174
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
175
|
+
try:
|
|
176
|
+
payload = json.loads(text) if text else {}
|
|
177
|
+
except Exception:
|
|
178
|
+
payload = {}
|
|
179
|
+
if res.status >= 400:
|
|
180
|
+
err = payload.get("error", "invalid_request") if isinstance(payload, dict) else "invalid_request"
|
|
181
|
+
desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
|
|
182
|
+
raise OAuthError(err, desc, status=res.status)
|
|
183
|
+
return payload if isinstance(payload, dict) else {}
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
# User info
|
|
187
|
+
# ------------------------------------------------------------------ #
|
|
188
|
+
async def fetch_user(self, access_token: str) -> dict[str, Any]:
|
|
189
|
+
url = f"{self.base_url}{ME_PATH}"
|
|
190
|
+
session = await self._get_session()
|
|
191
|
+
async with session.get(url, headers={"Authorization": f"Bearer {access_token}"}) as res:
|
|
192
|
+
raw = await res.read()
|
|
193
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(text) if text else {}
|
|
196
|
+
except Exception:
|
|
197
|
+
payload = {}
|
|
198
|
+
if res.status >= 400:
|
|
199
|
+
err = payload.get("error", "invalid_token") if isinstance(payload, dict) else "invalid_token"
|
|
200
|
+
desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
|
|
201
|
+
raise OAuthError(err, desc, status=res.status)
|
|
202
|
+
return payload if isinstance(payload, dict) else {}
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------ #
|
|
205
|
+
# Convenience
|
|
206
|
+
# ------------------------------------------------------------------ #
|
|
207
|
+
async def login(self, code: str, **kw: Any) -> dict[str, Any]:
|
|
208
|
+
"""Exchange ``code`` and immediately fetch the user. Returns ``{token, user}``."""
|
|
209
|
+
token = await self.exchange_code(code, **kw)
|
|
210
|
+
access_token = token.get("access_token")
|
|
211
|
+
user = await self.fetch_user(access_token) if access_token else None
|
|
212
|
+
return {"token": token, "user": user}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Aliases
|
|
216
|
+
YapperOAuth = OAuth2Client
|
|
217
|
+
LoginWithYapper = OAuth2Client
|
|
@@ -14,6 +14,7 @@ src/pyyapper/http.py
|
|
|
14
14
|
src/pyyapper/i18n.py
|
|
15
15
|
src/pyyapper/metrics.py
|
|
16
16
|
src/pyyapper/models.py
|
|
17
|
+
src/pyyapper/oauth.py
|
|
17
18
|
src/pyyapper/pagination.py
|
|
18
19
|
src/pyyapper/presence.py
|
|
19
20
|
src/pyyapper/py.typed
|
|
@@ -32,6 +33,7 @@ src/pyyapper/ext/commands/converters.py
|
|
|
32
33
|
src/pyyapper/ext/commands/core.py
|
|
33
34
|
src/pyyapper/ext/commands/errors.py
|
|
34
35
|
tests/test_ext_commands.py
|
|
36
|
+
tests/test_oauth.py
|
|
35
37
|
tests/test_smoke.py
|
|
36
38
|
tests/test_tasks.py
|
|
37
39
|
tests/test_webhooks.py
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from pyyapper import OAuth2Client, generate_state, generate_pkce, OAuthError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_auth_url():
|
|
7
|
+
c = OAuth2Client("cid", "secret", "https://example.com/cb")
|
|
8
|
+
url = c.authorization_url("st")
|
|
9
|
+
assert "client_id=cid" in url
|
|
10
|
+
assert "state=st" in url
|
|
11
|
+
assert "response_type=code" in url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_pkce():
|
|
15
|
+
v, ch = generate_pkce()
|
|
16
|
+
assert len(v) >= 43
|
|
17
|
+
assert len(ch) >= 43
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_generate_state():
|
|
21
|
+
s = generate_state()
|
|
22
|
+
assert len(s) >= 32
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_oauth_error():
|
|
26
|
+
e = OAuthError("invalid_grant", "bad code", status=400)
|
|
27
|
+
assert e.error == "invalid_grant"
|
|
28
|
+
assert e.status == 400
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_async_context_manager():
|
|
32
|
+
async def main():
|
|
33
|
+
async with OAuth2Client("cid", redirect_uri="https://example.com/cb") as c:
|
|
34
|
+
assert c.client_id == "cid"
|
|
35
|
+
asyncio.run(main())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|