-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoauth.py
More file actions
338 lines (297 loc) · 13.5 KB
/
Copy pathoauth.py
File metadata and controls
338 lines (297 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""User-scoped FXMacroData OAuth support for the AstrBot integration.
This module implements a device authorization flow because a chat command has
no private browser callback. Users enter credentials only on the first-party
FXMacroData verification page. AstrBot receives revocable OAuth tokens and
keeps them encrypted in its private plugin KV store.
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from typing import Any, Protocol
import httpx
from cryptography.fernet import Fernet, InvalidToken
# The dedicated API host publishes OAuth at its origin. The website's
# same-origin proxy exposes a separate `/api/...` alias, which this plugin does
# not use so browser verification and token polling stay on the canonical API
# host.
OAUTH_BASE_URL = "https://api.fxmacrodata.com/oauth"
ASTRBOT_DEVICE_CLIENT_ID = "astrbot-plugin-fxmacrodata"
TOKEN_ENCRYPTION_ENV = "FXMACRODATA_ASTRBOT_TOKEN_ENCRYPTION_KEY"
CREDENTIAL_STORAGE_KEY = "fxmacrodata_oauth_credentials_v1"
PENDING_DEVICE_STORAGE_KEY = "fxmacrodata_oauth_pending_device_v1"
EXPIRY_SKEW_SECONDS = 60
class OAuthError(RuntimeError):
"""A safe, user-facing OAuth transport or credential-store failure."""
class OAuthPendingError(OAuthError):
"""The user has not yet approved a device authorization."""
class OAuthConfigurationError(OAuthError):
"""The AstrBot operator has not supplied safe token encryption."""
class PluginKV(Protocol):
async def get_kv_data(self, key: str, default: Any) -> Any: ...
async def put_kv_data(self, key: str, value: Any) -> None: ...
@dataclass(frozen=True)
class DeviceAuthorization:
"""Non-secret details that AstrBot may show to the person signing in."""
verification_uri_complete: str
user_code: str
expires_in: int
interval: int
class FXMacroDataOAuthClient:
"""Minimal OAuth client which never logs request bodies or token values."""
def __init__(self, *, timeout_seconds: int = 45, base_url: str = OAUTH_BASE_URL):
self._timeout_seconds = timeout_seconds
self._base_url = base_url.rstrip("/")
@staticmethod
def _error_from_response(response: httpx.Response) -> OAuthError:
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail", payload) if isinstance(payload, dict) else {}
error = detail.get("error") if isinstance(detail, dict) else None
description = (
detail.get("error_description") if isinstance(detail, dict) else None
)
if error == "authorization_pending":
return OAuthPendingError(
"Finish approval in your FXMacroData browser page."
)
if error == "slow_down":
return OAuthPendingError("Please wait a few seconds, then check again.")
if error in {"expired_token", "invalid_user_code"}:
return OAuthError("This sign-in request expired. Start a new connection.")
if error == "access_denied":
return OAuthError("FXMacroData did not approve this connection.")
if response.status_code == 401:
return OAuthError(
"FXMacroData authorization was rejected. Reconnect to continue."
)
if response.status_code >= 500:
return OAuthError("FXMacroData authorization is temporarily unavailable.")
return OAuthError(
description or "FXMacroData authorization could not be completed."
)
async def _post(self, path: str, data: dict[str, str]) -> dict[str, Any]:
try:
async with httpx.AsyncClient(
timeout=self._timeout_seconds, follow_redirects=False
) as client:
response = await client.post(f"{self._base_url}{path}", data=data)
except httpx.HTTPError as exc:
raise OAuthError(
"FXMacroData authorization is unavailable. Try again."
) from exc
if response.status_code >= 400:
raise self._error_from_response(response)
try:
payload = response.json()
except ValueError as exc:
raise OAuthError(
"FXMacroData returned an invalid authorization response."
) from exc
if not isinstance(payload, dict):
raise OAuthError("FXMacroData returned an invalid authorization response.")
return payload
async def start_device_authorization(self) -> tuple[DeviceAuthorization, str]:
payload = await self._post(
"/device/authorize",
{
"client_id": ASTRBOT_DEVICE_CLIENT_ID,
"scope": "fxmacrodata.read",
},
)
device_code = payload.get("device_code")
verification_uri_complete = payload.get("verification_uri_complete")
user_code = payload.get("user_code")
if not all(
isinstance(value, str) and value
for value in (device_code, verification_uri_complete, user_code)
):
raise OAuthError("FXMacroData returned an incomplete sign-in request.")
try:
expires_in = max(int(payload.get("expires_in") or 0), 1)
interval = max(int(payload.get("interval") or 5), 1)
except (TypeError, ValueError) as exc:
raise OAuthError(
"FXMacroData returned an invalid sign-in request."
) from exc
return (
DeviceAuthorization(
verification_uri_complete=verification_uri_complete,
user_code=user_code,
expires_in=expires_in,
interval=interval,
),
device_code,
)
async def exchange_device_code(self, device_code: str) -> dict[str, Any]:
return await self._post(
"/token",
{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
"client_id": ASTRBOT_DEVICE_CLIENT_ID,
},
)
async def refresh(self, refresh_token: str) -> dict[str, Any]:
return await self._post(
"/token",
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": ASTRBOT_DEVICE_CLIENT_ID,
},
)
async def revoke(self, refresh_token: str) -> None:
try:
await self._post(
"/revoke",
{
"token": refresh_token,
"token_type_hint": "refresh_token",
"client_id": ASTRBOT_DEVICE_CLIENT_ID,
},
)
except OAuthError:
# Local encrypted data is still removed, including when the user
# intentionally disconnects while offline.
return
class EncryptedOAuthVault:
"""Encrypt and scope OAuth state to one AstrBot identity."""
def __init__(self, plugin: PluginKV, oauth_client: FXMacroDataOAuthClient) -> None:
self._plugin = plugin
self._oauth_client = oauth_client
@staticmethod
def _fernet() -> Fernet:
configured = os.getenv(TOKEN_ENCRYPTION_ENV, "").strip()
if not configured:
raise OAuthConfigurationError(
"The AstrBot operator must set FXMACRODATA_ASTRBOT_TOKEN_ENCRYPTION_KEY before users can connect protected FXMacroData access."
)
try:
return Fernet(configured.encode("ascii"))
except (UnicodeEncodeError, ValueError) as exc:
raise OAuthConfigurationError(
"The AstrBot token-encryption key is invalid. Ask the operator to replace it."
) from exc
async def _load_map(self, key: str) -> dict[str, dict[str, Any]]:
raw = await self._plugin.get_kv_data(key, {})
if not isinstance(raw, dict):
return {}
return {
identity: dict(value)
for identity, value in raw.items()
if isinstance(identity, str) and isinstance(value, dict)
}
@staticmethod
def _encrypt(fernet: Fernet, value: str) -> str:
return fernet.encrypt(value.encode("utf-8")).decode("ascii")
@staticmethod
def _decrypt(fernet: Fernet, value: Any) -> str | None:
if not isinstance(value, str):
return None
try:
return fernet.decrypt(value.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError, ValueError):
return None
@staticmethod
def _expiry(payload: dict[str, Any], field: str = "expires_in") -> float:
try:
seconds = max(int(payload.get(field) or 0), 1)
except (TypeError, ValueError) as exc:
raise OAuthError("FXMacroData returned an invalid token response.") from exc
return time.time() + seconds
async def start(self, identity: str) -> DeviceAuthorization:
fernet = self._fernet()
(
authorization,
device_code,
) = await self._oauth_client.start_device_authorization()
pending = await self._load_map(PENDING_DEVICE_STORAGE_KEY)
pending[identity] = {
"device_code": self._encrypt(fernet, device_code),
"expires_at": time.time() + authorization.expires_in,
}
await self._plugin.put_kv_data(PENDING_DEVICE_STORAGE_KEY, pending)
return authorization
async def complete(self, identity: str) -> None:
fernet = self._fernet()
pending = await self._load_map(PENDING_DEVICE_STORAGE_KEY)
state = pending.get(identity)
if not state:
raise OAuthError("Start FXMacroData connection first.")
try:
expired = float(state.get("expires_at") or 0) <= time.time()
except (TypeError, ValueError):
expired = True
device_code = self._decrypt(fernet, state.get("device_code"))
if expired or not device_code:
pending.pop(identity, None)
await self._plugin.put_kv_data(PENDING_DEVICE_STORAGE_KEY, pending)
raise OAuthError("This sign-in request expired. Start a new connection.")
token_response = await self._oauth_client.exchange_device_code(device_code)
await self._save_token_response(identity, token_response, fernet=fernet)
pending.pop(identity, None)
await self._plugin.put_kv_data(PENDING_DEVICE_STORAGE_KEY, pending)
async def _save_token_response(
self, identity: str, token_response: dict[str, Any], *, fernet: Fernet
) -> None:
access_token = token_response.get("access_token")
refresh_token = token_response.get("refresh_token")
if not isinstance(access_token, str) or not isinstance(refresh_token, str):
raise OAuthError("FXMacroData returned an incomplete token response.")
credentials = await self._load_map(CREDENTIAL_STORAGE_KEY)
credentials[identity] = {
"access_token": self._encrypt(fernet, access_token),
"refresh_token": self._encrypt(fernet, refresh_token),
"expires_at": self._expiry(token_response),
"refresh_expires_at": self._expiry(token_response, "refresh_expires_in"),
"scope": str(token_response.get("scope") or "fxmacrodata.read"),
}
await self._plugin.put_kv_data(CREDENTIAL_STORAGE_KEY, credentials)
async def access_token(self, identity: str) -> str | None:
fernet = self._fernet()
credentials = await self._load_map(CREDENTIAL_STORAGE_KEY)
state = credentials.get(identity)
if not state:
return None
access_token = self._decrypt(fernet, state.get("access_token"))
try:
expires_at = float(state.get("expires_at") or 0)
except (TypeError, ValueError):
expires_at = 0
if access_token and expires_at > time.time() + EXPIRY_SKEW_SECONDS:
return access_token
refresh_token = self._decrypt(fernet, state.get("refresh_token"))
try:
refresh_expires_at = float(state.get("refresh_expires_at") or 0)
except (TypeError, ValueError):
refresh_expires_at = 0
if not refresh_token or refresh_expires_at <= time.time():
credentials.pop(identity, None)
await self._plugin.put_kv_data(CREDENTIAL_STORAGE_KEY, credentials)
return None
try:
token_response = await self._oauth_client.refresh(refresh_token)
await self._save_token_response(identity, token_response, fernet=fernet)
except OAuthError:
credentials = await self._load_map(CREDENTIAL_STORAGE_KEY)
credentials.pop(identity, None)
await self._plugin.put_kv_data(CREDENTIAL_STORAGE_KEY, credentials)
return None
return self._decrypt(fernet, token_response.get("access_token"))
async def is_connected(self, identity: str) -> bool:
return await self.access_token(identity) is not None
async def disconnect(self, identity: str) -> None:
fernet = self._fernet()
credentials = await self._load_map(CREDENTIAL_STORAGE_KEY)
state = credentials.pop(identity, None)
await self._plugin.put_kv_data(CREDENTIAL_STORAGE_KEY, credentials)
pending = await self._load_map(PENDING_DEVICE_STORAGE_KEY)
pending.pop(identity, None)
await self._plugin.put_kv_data(PENDING_DEVICE_STORAGE_KEY, pending)
if state:
refresh_token = self._decrypt(fernet, state.get("refresh_token"))
if refresh_token:
await self._oauth_client.revoke(refresh_token)