authorizer-py 0.1.0__py3-none-any.whl

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.
authorizer/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """Python SDK for authorizer.dev — self-hosted authentication & authorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .async_client import AsyncAuthorizerClient
6
+ from .client import AuthorizerClient
7
+ from .exceptions import AuthorizerConnectionError, AuthorizerError
8
+ from .types import (
9
+ AuthToken,
10
+ CheckPermissionsRequest,
11
+ CheckPermissionsResponse,
12
+ FgaTupleInput,
13
+ ForgotPasswordRequest,
14
+ ForgotPasswordResponse,
15
+ GenericResponse,
16
+ GetTokenRequest,
17
+ GetTokenResponse,
18
+ ListPermissionsRequest,
19
+ ListPermissionsResponse,
20
+ LoginRequest,
21
+ MagicLinkLoginRequest,
22
+ MetaData,
23
+ OAuthProviders,
24
+ Permission,
25
+ PermissionCheckInput,
26
+ PermissionCheckResult,
27
+ ResendOTPRequest,
28
+ ResendVerifyEmailRequest,
29
+ ResetPasswordRequest,
30
+ ResponseTypes,
31
+ RevokeTokenRequest,
32
+ SessionQueryRequest,
33
+ SignUpRequest,
34
+ TokenType,
35
+ UpdateProfileRequest,
36
+ User,
37
+ ValidateJWTTokenRequest,
38
+ ValidateJWTTokenResponse,
39
+ ValidateSessionRequest,
40
+ ValidateSessionResponse,
41
+ VerifyEmailRequest,
42
+ VerifyOTPRequest,
43
+ )
44
+
45
+ __version__ = "0.1.0"
46
+
47
+ __all__ = [
48
+ "AuthorizerClient",
49
+ "AsyncAuthorizerClient",
50
+ "AuthorizerError",
51
+ "AuthorizerConnectionError",
52
+ "AuthToken",
53
+ "CheckPermissionsRequest",
54
+ "CheckPermissionsResponse",
55
+ "FgaTupleInput",
56
+ "ForgotPasswordRequest",
57
+ "ForgotPasswordResponse",
58
+ "GenericResponse",
59
+ "GetTokenRequest",
60
+ "GetTokenResponse",
61
+ "ListPermissionsRequest",
62
+ "ListPermissionsResponse",
63
+ "LoginRequest",
64
+ "MagicLinkLoginRequest",
65
+ "MetaData",
66
+ "OAuthProviders",
67
+ "Permission",
68
+ "PermissionCheckInput",
69
+ "PermissionCheckResult",
70
+ "ResendOTPRequest",
71
+ "ResendVerifyEmailRequest",
72
+ "ResetPasswordRequest",
73
+ "ResponseTypes",
74
+ "RevokeTokenRequest",
75
+ "SessionQueryRequest",
76
+ "SignUpRequest",
77
+ "TokenType",
78
+ "UpdateProfileRequest",
79
+ "User",
80
+ "ValidateJWTTokenRequest",
81
+ "ValidateJWTTokenResponse",
82
+ "ValidateSessionRequest",
83
+ "ValidateSessionResponse",
84
+ "VerifyEmailRequest",
85
+ "VerifyOTPRequest",
86
+ "__version__",
87
+ ]
authorizer/_core.py ADDED
@@ -0,0 +1,147 @@
1
+ """I/O-free request building and response parsing shared by both clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from .exceptions import AuthorizerError
11
+
12
+
13
+ @dataclass
14
+ class ClientConfig:
15
+ client_id: str
16
+ authorizer_url: str
17
+ redirect_url: str
18
+ extra_headers: dict[str, str]
19
+
20
+
21
+ @dataclass
22
+ class RequestSpec:
23
+ method: str
24
+ url: str
25
+ headers: dict[str, str]
26
+ json: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ def _origin_from_url(url: str) -> str | None:
30
+ parsed = urlparse(url)
31
+ if parsed.scheme and parsed.netloc:
32
+ return f"{parsed.scheme}://{parsed.netloc}"
33
+ return None
34
+
35
+
36
+ def build_headers(config: ClientConfig, per_call: dict[str, str] | None) -> dict[str, str]:
37
+ """Assemble headers: identity headers, extra headers, per-call overrides, default Origin."""
38
+ headers: dict[str, str] = {
39
+ "Content-Type": "application/json",
40
+ "x-authorizer-url": config.authorizer_url,
41
+ "x-authorizer-client-id": config.client_id,
42
+ }
43
+ headers.update(config.extra_headers)
44
+ if per_call:
45
+ headers.update(per_call)
46
+ # CSRF guard (Authorizer >= v2.3.0) needs an Origin on state-changing requests.
47
+ # The server's own origin always passes the same-origin rule under wildcard
48
+ # ALLOWED_ORIGINS. Callers may override via extra/per-call headers.
49
+ if "Origin" not in headers:
50
+ origin = _origin_from_url(config.authorizer_url)
51
+ if origin:
52
+ headers["Origin"] = origin
53
+ return headers
54
+
55
+
56
+ def build_graphql_request(
57
+ authorizer_url: str,
58
+ query: str,
59
+ variables: dict[str, Any] | None,
60
+ headers: dict[str, str],
61
+ ) -> RequestSpec:
62
+ body: dict[str, Any] = {"query": query}
63
+ if variables:
64
+ body["variables"] = variables
65
+ return RequestSpec("POST", f"{authorizer_url}/graphql", headers, body)
66
+
67
+
68
+ def build_oauth_request(
69
+ authorizer_url: str,
70
+ path: str,
71
+ body: dict[str, Any],
72
+ headers: dict[str, str],
73
+ ) -> RequestSpec:
74
+ return RequestSpec("POST", f"{authorizer_url}{path}", headers, body)
75
+
76
+
77
+ def _decode(body: bytes) -> Any:
78
+ if not body:
79
+ return None
80
+ try:
81
+ return _json.loads(body)
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def _raise_for_graphql_errors(status: int, decoded: Any, body: bytes) -> None:
87
+ """Raise AuthorizerError if *decoded* contains a GraphQL errors array or status >= 400."""
88
+ if isinstance(decoded, dict):
89
+ errors = decoded.get("errors")
90
+ if errors:
91
+ message = "request failed"
92
+ if isinstance(errors, list) and errors:
93
+ first = errors[0]
94
+ if isinstance(first, dict) and first.get("message"):
95
+ message = str(first["message"])
96
+ elif isinstance(errors, str) and errors:
97
+ message = errors
98
+ raise AuthorizerError(
99
+ message,
100
+ errors=errors if isinstance(errors, list) else [errors],
101
+ status=status,
102
+ )
103
+ if status >= 400:
104
+ text = body.decode("utf-8", "replace") if body else ""
105
+ raise AuthorizerError(f"HTTP {status}: {text}".strip(), status=status)
106
+
107
+
108
+ def parse_graphql_response(status: int, body: bytes, field_name: str) -> dict[str, Any] | None:
109
+ """Return ``data[field_name]`` or raise AuthorizerError.
110
+
111
+ Mirrors authorizer-go: a non-empty ``errors`` array is an API error; a
112
+ >=400 status with no ``errors`` array (CSRF 403, proxy page) is also an error.
113
+ """
114
+ decoded = _decode(body)
115
+ _raise_for_graphql_errors(status, decoded, body)
116
+ if isinstance(decoded, dict):
117
+ data = decoded.get("data")
118
+ if isinstance(data, dict):
119
+ return data.get(field_name)
120
+ return None
121
+
122
+
123
+ def parse_graphql_data(status: int, body: bytes) -> dict[str, Any]:
124
+ """Return the whole GraphQL ``data`` object (or {}), raising on errors.
125
+
126
+ Behaves like :func:`parse_graphql_response` but returns the full ``data``
127
+ dict instead of a single named field. Intended for :meth:`graphql_query`.
128
+ """
129
+ decoded = _decode(body)
130
+ _raise_for_graphql_errors(status, decoded, body)
131
+ if isinstance(decoded, dict):
132
+ data = decoded.get("data")
133
+ if isinstance(data, dict):
134
+ return data
135
+ return {}
136
+
137
+
138
+ def parse_oauth_response(status: int, body: bytes) -> dict[str, Any]:
139
+ """Return parsed OAuth JSON or raise AuthorizerError using error fields."""
140
+ decoded = _decode(body)
141
+ payload: dict[str, Any] = decoded if isinstance(decoded, dict) else {}
142
+ if status >= 400:
143
+ message = str(
144
+ payload.get("error_description") or payload.get("error") or f"HTTP {status}"
145
+ )
146
+ raise AuthorizerError(message, status=status)
147
+ return payload
authorizer/_queries.py ADDED
@@ -0,0 +1,110 @@
1
+ """GraphQL fragments and query/mutation strings used by the SDK.
2
+
3
+ Mirrors the queries in authorizer-go so server behavior is identical.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ USER_FRAGMENT = (
9
+ "id email email_verified given_name family_name middle_name nickname "
10
+ "preferred_username picture signup_methods gender birthdate phone_number "
11
+ "phone_number_verified roles created_at updated_at is_multi_factor_auth_enabled "
12
+ "app_data revoked_timestamp"
13
+ )
14
+
15
+ AUTH_TOKEN_FRAGMENT = (
16
+ "message access_token expires_in refresh_token id_token "
17
+ "should_show_email_otp_screen should_show_mobile_otp_screen should_show_totp_screen "
18
+ "authenticator_scanner_image authenticator_secret authenticator_recovery_codes "
19
+ f"user {{ {USER_FRAGMENT} }}"
20
+ )
21
+
22
+ LOGIN = (
23
+ "mutation login($data: LoginRequest!) "
24
+ f"{{ login(params: $data) {{ {AUTH_TOKEN_FRAGMENT} }} }}"
25
+ )
26
+
27
+ SIGNUP = (
28
+ "mutation signup($data: SignUpRequest!) "
29
+ f"{{ signup(params: $data) {{ {AUTH_TOKEN_FRAGMENT} }} }}"
30
+ )
31
+
32
+ MAGIC_LINK_LOGIN = (
33
+ "mutation magicLinkLogin($data: MagicLinkLoginRequest!) "
34
+ "{ magic_link_login(params: $data) { message } }"
35
+ )
36
+
37
+ VERIFY_OTP = (
38
+ "mutation verifyOtp($data: VerifyOTPRequest!) "
39
+ f"{{ verify_otp(params: $data) {{ {AUTH_TOKEN_FRAGMENT} }} }}"
40
+ )
41
+
42
+ VERIFY_EMAIL = (
43
+ "mutation verifyEmail($data: VerifyEmailRequest!) "
44
+ f"{{ verify_email(params: $data) {{ {AUTH_TOKEN_FRAGMENT} }} }}"
45
+ )
46
+
47
+ RESEND_OTP = (
48
+ "mutation resendOtp($data: ResendOTPRequest!) { resend_otp(params: $data) { message } }"
49
+ )
50
+
51
+ RESEND_VERIFY_EMAIL = (
52
+ "mutation resendVerifyEmail($data: ResendVerifyEmailRequest!) "
53
+ "{ resend_verify_email(params: $data) { message } }"
54
+ )
55
+
56
+ FORGOT_PASSWORD = (
57
+ "mutation forgotPassword($data: ForgotPasswordRequest!) "
58
+ "{ forgot_password(params: $data) { message should_show_mobile_otp_screen } }"
59
+ )
60
+
61
+ RESET_PASSWORD = (
62
+ "mutation resetPassword($data: ResetPasswordRequest!) "
63
+ "{ reset_password(params: $data) { message } }"
64
+ )
65
+
66
+ VALIDATE_JWT_TOKEN = (
67
+ "query validateJWTToken($data: ValidateJWTTokenRequest!)"
68
+ "{ validate_jwt_token(params: $data) { is_valid claims } }"
69
+ )
70
+
71
+ VALIDATE_SESSION = (
72
+ "query validateSession($data: ValidateSessionRequest!)"
73
+ f"{{ validate_session(params: $data) {{ is_valid user {{ {USER_FRAGMENT} }} }} }}"
74
+ )
75
+
76
+ META = (
77
+ "query { meta { version client_id is_google_login_enabled is_facebook_login_enabled "
78
+ "is_github_login_enabled is_linkedin_login_enabled is_apple_login_enabled "
79
+ "is_twitter_login_enabled is_discord_login_enabled is_microsoft_login_enabled "
80
+ "is_twitch_login_enabled is_roblox_login_enabled is_email_verification_enabled "
81
+ "is_basic_authentication_enabled is_magic_link_login_enabled is_sign_up_enabled "
82
+ "is_strong_password_enabled is_multi_factor_auth_enabled "
83
+ "is_mobile_basic_authentication_enabled is_phone_verification_enabled } }"
84
+ )
85
+
86
+ SESSION = (
87
+ "query getSession($data: SessionQueryRequest) "
88
+ f"{{ session(params: $data) {{ {AUTH_TOKEN_FRAGMENT} }} }}"
89
+ )
90
+
91
+ PROFILE = f"query {{ profile {{ {USER_FRAGMENT} }} }}"
92
+
93
+ UPDATE_PROFILE = (
94
+ "mutation updateProfile($data: UpdateProfileRequest!) "
95
+ "{ update_profile(params: $data) { message } }"
96
+ )
97
+
98
+ LOGOUT = "mutation { logout { message } }"
99
+
100
+ DEACTIVATE_ACCOUNT = "mutation deactivateAccount { deactivate_account { message } }"
101
+
102
+ CHECK_PERMISSIONS = (
103
+ "query checkPermissions($data: CheckPermissionsInput!)"
104
+ "{ check_permissions(params: $data) { results { relation object allowed } } }"
105
+ )
106
+
107
+ LIST_PERMISSIONS = (
108
+ "query listPermissions($data: ListPermissionsInput!)"
109
+ "{ list_permissions(params: $data) { objects permissions { object relation } truncated } }"
110
+ )
@@ -0,0 +1,232 @@
1
+ """Asynchronous Authorizer client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from . import _queries as q
11
+ from . import types as t
12
+ from ._core import (
13
+ ClientConfig,
14
+ RequestSpec,
15
+ build_graphql_request,
16
+ build_headers,
17
+ build_oauth_request,
18
+ parse_graphql_data,
19
+ parse_graphql_response,
20
+ parse_oauth_response,
21
+ )
22
+ from .exceptions import AuthorizerConnectionError
23
+
24
+
25
+ class AsyncAuthorizerClient:
26
+ """Asynchronous client for an Authorizer instance."""
27
+
28
+ def __init__(
29
+ self,
30
+ client_id: str,
31
+ authorizer_url: str,
32
+ redirect_url: str = "",
33
+ extra_headers: dict[str, str] | None = None,
34
+ ) -> None:
35
+ if not client_id or not client_id.strip():
36
+ raise ValueError("client_id is required")
37
+ if not authorizer_url or not authorizer_url.strip():
38
+ raise ValueError("authorizer_url is required")
39
+ self._config = ClientConfig(
40
+ client_id=client_id,
41
+ authorizer_url=authorizer_url.strip().rstrip("/"),
42
+ redirect_url=redirect_url.strip().rstrip("/"),
43
+ extra_headers=dict(extra_headers or {}),
44
+ )
45
+ self._http = httpx.AsyncClient()
46
+
47
+ # -- lifecycle -------------------------------------------------------- #
48
+ async def aclose(self) -> None:
49
+ await self._http.aclose()
50
+
51
+ async def __aenter__(self) -> AsyncAuthorizerClient:
52
+ return self
53
+
54
+ async def __aexit__(
55
+ self,
56
+ exc_type: type[BaseException] | None,
57
+ exc: BaseException | None,
58
+ tb: TracebackType | None,
59
+ ) -> None:
60
+ await self.aclose()
61
+
62
+ # -- low-level send --------------------------------------------------- #
63
+ async def _send(self, spec: RequestSpec) -> httpx.Response:
64
+ try:
65
+ return await self._http.request(
66
+ spec.method, spec.url, json=spec.json, headers=spec.headers
67
+ )
68
+ except httpx.HTTPError as e: # network/transport failure
69
+ raise AuthorizerConnectionError(str(e)) from e
70
+
71
+ async def _graphql(
72
+ self,
73
+ query: str,
74
+ field_name: str,
75
+ variables: dict[str, Any] | None = None,
76
+ headers: dict[str, str] | None = None,
77
+ ) -> dict[str, Any] | None:
78
+ spec = build_graphql_request(
79
+ self._config.authorizer_url, query, variables, build_headers(self._config, headers)
80
+ )
81
+ res = await self._send(spec)
82
+ return parse_graphql_response(res.status_code, res.content, field_name)
83
+
84
+ async def _oauth(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
85
+ spec = build_oauth_request(
86
+ self._config.authorizer_url, path, body, build_headers(self._config, None)
87
+ )
88
+ res = await self._send(spec)
89
+ return parse_oauth_response(res.status_code, res.content)
90
+
91
+ # -- public auth flows ----------------------------------------------- #
92
+ async def login(self, req: t.LoginRequest) -> t.AuthToken:
93
+ res = await self._graphql(q.LOGIN, "login", {"data": req.to_dict()})
94
+ return t.AuthToken.from_dict(res or {})
95
+
96
+ async def signup(self, req: t.SignUpRequest) -> t.AuthToken:
97
+ res = await self._graphql(q.SIGNUP, "signup", {"data": req.to_dict()})
98
+ return t.AuthToken.from_dict(res or {})
99
+
100
+ async def magic_link_login(self, req: t.MagicLinkLoginRequest) -> t.GenericResponse:
101
+ payload = req.to_dict()
102
+ if not payload.get("redirect_uri") and self._config.redirect_url:
103
+ payload["redirect_uri"] = self._config.redirect_url
104
+ res = await self._graphql(q.MAGIC_LINK_LOGIN, "magic_link_login", {"data": payload})
105
+ return t.GenericResponse.from_dict(res or {})
106
+
107
+ async def verify_otp(self, req: t.VerifyOTPRequest) -> t.AuthToken:
108
+ res = await self._graphql(q.VERIFY_OTP, "verify_otp", {"data": req.to_dict()})
109
+ return t.AuthToken.from_dict(res or {})
110
+
111
+ async def verify_email(self, req: t.VerifyEmailRequest) -> t.AuthToken:
112
+ res = await self._graphql(q.VERIFY_EMAIL, "verify_email", {"data": req.to_dict()})
113
+ return t.AuthToken.from_dict(res or {})
114
+
115
+ async def resend_otp(self, req: t.ResendOTPRequest) -> t.GenericResponse:
116
+ res = await self._graphql(q.RESEND_OTP, "resend_otp", {"data": req.to_dict()})
117
+ return t.GenericResponse.from_dict(res or {})
118
+
119
+ async def resend_verify_email(self, req: t.ResendVerifyEmailRequest) -> t.GenericResponse:
120
+ res = await self._graphql(
121
+ q.RESEND_VERIFY_EMAIL, "resend_verify_email", {"data": req.to_dict()}
122
+ )
123
+ return t.GenericResponse.from_dict(res or {})
124
+
125
+ async def forgot_password(self, req: t.ForgotPasswordRequest) -> t.ForgotPasswordResponse:
126
+ payload = req.to_dict()
127
+ if not payload.get("redirect_uri") and self._config.redirect_url:
128
+ payload["redirect_uri"] = self._config.redirect_url
129
+ res = await self._graphql(q.FORGOT_PASSWORD, "forgot_password", {"data": payload})
130
+ return t.ForgotPasswordResponse.from_dict(res or {})
131
+
132
+ async def reset_password(self, req: t.ResetPasswordRequest) -> t.GenericResponse:
133
+ res = await self._graphql(q.RESET_PASSWORD, "reset_password", {"data": req.to_dict()})
134
+ return t.GenericResponse.from_dict(res or {})
135
+
136
+ async def validate_jwt_token(
137
+ self, req: t.ValidateJWTTokenRequest
138
+ ) -> t.ValidateJWTTokenResponse:
139
+ res = await self._graphql(
140
+ q.VALIDATE_JWT_TOKEN, "validate_jwt_token", {"data": req.to_dict()}
141
+ )
142
+ return t.ValidateJWTTokenResponse.from_dict(res or {})
143
+
144
+ async def validate_session(self, req: t.ValidateSessionRequest) -> t.ValidateSessionResponse:
145
+ res = await self._graphql(
146
+ q.VALIDATE_SESSION, "validate_session", {"data": req.to_dict()}
147
+ )
148
+ return t.ValidateSessionResponse.from_dict(res or {})
149
+
150
+ async def get_meta_data(self) -> t.MetaData:
151
+ res = await self._graphql(q.META, "meta")
152
+ return t.MetaData.from_dict(res or {})
153
+
154
+ # -- authenticated (credential headers) ------------------------------ #
155
+ async def get_session(
156
+ self, req: t.SessionQueryRequest | None = None, headers: dict[str, str] | None = None
157
+ ) -> t.AuthToken:
158
+ variables = {"data": req.to_dict()} if req is not None else None
159
+ res = await self._graphql(q.SESSION, "session", variables, headers)
160
+ return t.AuthToken.from_dict(res or {})
161
+
162
+ async def get_profile(self, headers: dict[str, str] | None = None) -> t.User:
163
+ res = await self._graphql(q.PROFILE, "profile", None, headers)
164
+ return t.User.from_dict(res or {})
165
+
166
+ async def update_profile(
167
+ self, req: t.UpdateProfileRequest, headers: dict[str, str] | None = None
168
+ ) -> t.GenericResponse:
169
+ res = await self._graphql(
170
+ q.UPDATE_PROFILE, "update_profile", {"data": req.to_dict()}, headers
171
+ )
172
+ return t.GenericResponse.from_dict(res or {})
173
+
174
+ async def logout(self, headers: dict[str, str] | None = None) -> t.GenericResponse:
175
+ res = await self._graphql(q.LOGOUT, "logout", None, headers)
176
+ return t.GenericResponse.from_dict(res or {})
177
+
178
+ async def deactivate_account(self, headers: dict[str, str] | None = None) -> t.GenericResponse:
179
+ res = await self._graphql(q.DEACTIVATE_ACCOUNT, "deactivate_account", None, headers)
180
+ return t.GenericResponse.from_dict(res or {})
181
+
182
+ async def check_permissions(
183
+ self, req: t.CheckPermissionsRequest, headers: dict[str, str] | None = None
184
+ ) -> t.CheckPermissionsResponse:
185
+ res = await self._graphql(
186
+ q.CHECK_PERMISSIONS, "check_permissions", {"data": req.to_dict()}, headers
187
+ )
188
+ return t.CheckPermissionsResponse.from_dict(res or {})
189
+
190
+ async def list_permissions(
191
+ self, req: t.ListPermissionsRequest, headers: dict[str, str] | None = None
192
+ ) -> t.ListPermissionsResponse:
193
+ res = await self._graphql(
194
+ q.LIST_PERMISSIONS, "list_permissions", {"data": req.to_dict()}, headers
195
+ )
196
+ return t.ListPermissionsResponse.from_dict(res or {})
197
+
198
+ # -- OAuth REST ------------------------------------------------------- #
199
+ async def get_token(self, req: t.GetTokenRequest) -> t.GetTokenResponse:
200
+ grant_type = req.grant_type or "authorization_code"
201
+ if grant_type == "refresh_token" and not (req.refresh_token and req.refresh_token.strip()):
202
+ raise ValueError("refresh_token is required for refresh_token grant")
203
+ body: dict[str, Any] = {
204
+ "client_id": self._config.client_id,
205
+ "code": req.code or "",
206
+ "code_verifier": req.code_verifier or "",
207
+ "grant_type": grant_type,
208
+ "refresh_token": req.refresh_token or "",
209
+ }
210
+ return t.GetTokenResponse.from_dict(await self._oauth("/oauth/token", body))
211
+
212
+ async def revoke_token(self, req: t.RevokeTokenRequest) -> t.GenericResponse:
213
+ if not req.refresh_token or not req.refresh_token.strip():
214
+ raise ValueError("refresh_token is required")
215
+ body: dict[str, Any] = {
216
+ "refresh_token": req.refresh_token,
217
+ "client_id": self._config.client_id,
218
+ }
219
+ return t.GenericResponse.from_dict(await self._oauth("/oauth/revoke", body))
220
+
221
+ # -- escape hatch ----------------------------------------------------- #
222
+ async def graphql_query(
223
+ self,
224
+ query: str,
225
+ variables: dict[str, Any] | None = None,
226
+ headers: dict[str, str] | None = None,
227
+ ) -> dict[str, Any]:
228
+ spec = build_graphql_request(
229
+ self._config.authorizer_url, query, variables, build_headers(self._config, headers)
230
+ )
231
+ res = await self._send(spec)
232
+ return parse_graphql_data(res.status_code, res.content)