Skip to content

Commit 98a0c58

Browse files
add oauth helper methods
1 parent add3260 commit 98a0c58

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed

plane/oauth/__init__.py

Whitespace-only changes.

plane/oauth/api.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# coding: utf-8
2+
3+
"""
4+
OAuth API helpers for Plane SDK using urllib3
5+
"""
6+
7+
import base64
8+
import json
9+
import logging
10+
from typing import Optional
11+
from urllib.parse import urlencode
12+
13+
from plane.configuration import Configuration
14+
from plane.exceptions import ApiException
15+
from plane.rest import RESTClientObject
16+
17+
from .models import PlaneOAuthAppInstallation, PlaneOAuthTokenResponse
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class OAuthApi:
23+
"""OAuth API helper class using urllib3."""
24+
25+
def __init__(
26+
self,
27+
client_id: str,
28+
client_secret: str,
29+
redirect_uri: str,
30+
base_url: str = "https://api.plane.so",
31+
configuration: Optional[Configuration] = None,
32+
):
33+
self.client_id = client_id
34+
self.client_secret = client_secret
35+
self.redirect_uri = redirect_uri
36+
self.base_url = base_url.rstrip("/")
37+
38+
if configuration is None:
39+
configuration = Configuration(host=base_url)
40+
41+
self.configuration = configuration
42+
self.rest_client = RESTClientObject(configuration)
43+
44+
def get_authorization_url(
45+
self,
46+
response_type: str = "code",
47+
state: Optional[str] = None,
48+
) -> str:
49+
"""Get the authorization URL for the OAuth flow."""
50+
params = {
51+
"client_id": self.client_id,
52+
"response_type": response_type,
53+
"redirect_uri": self.redirect_uri,
54+
}
55+
56+
if state:
57+
params["state"] = state
58+
59+
return f"{self.base_url}/auth/o/authorize-app/?{urlencode(params)}"
60+
61+
def exchange_code_for_token(
62+
self, code: str, grant_type: str = "authorization_code"
63+
) -> PlaneOAuthTokenResponse:
64+
"""Exchange authorization code for access token."""
65+
data = {
66+
"grant_type": grant_type,
67+
"code": code,
68+
"client_id": self.client_id,
69+
"client_secret": self.client_secret,
70+
"redirect_uri": self.redirect_uri,
71+
}
72+
73+
headers = {
74+
"Cache-Control": "no-cache",
75+
"Content-Type": "application/x-www-form-urlencoded",
76+
}
77+
78+
try:
79+
response = self.rest_client.post_request(
80+
url=f"{self.base_url}/auth/o/token/", headers=headers, post_params=data
81+
)
82+
83+
response_data = json.loads(response.data.decode("utf-8"))
84+
return PlaneOAuthTokenResponse.validate(response_data)
85+
86+
except ApiException as e:
87+
logger.error(f"Failed to exchange code for token: {e}")
88+
raise
89+
except Exception as e:
90+
logger.error(f"Unexpected error during token exchange: {e}")
91+
raise ApiException(status=0, reason=str(e))
92+
93+
def get_refresh_token(self, refresh_token: str) -> PlaneOAuthTokenResponse:
94+
"""Get a new access token using a refresh token."""
95+
data = {
96+
"grant_type": "refresh_token",
97+
"refresh_token": refresh_token,
98+
"client_id": self.client_id,
99+
"client_secret": self.client_secret,
100+
}
101+
102+
headers = {
103+
"Cache-Control": "no-cache",
104+
"Content-Type": "application/x-www-form-urlencoded",
105+
}
106+
107+
try:
108+
response = self.rest_client.post_request(
109+
url=f"{self.base_url}/auth/o/token/", headers=headers, post_params=data
110+
)
111+
112+
response_data = json.loads(response.data.decode("utf-8"))
113+
return PlaneOAuthTokenResponse.validate(response_data)
114+
115+
except ApiException as e:
116+
logger.error(f"Failed to refresh token: {e}")
117+
raise
118+
except Exception as e:
119+
logger.error(f"Unexpected error during token refresh: {e}")
120+
raise ApiException(status=0, reason=str(e))
121+
122+
def get_bot_token(self, app_installation_id: str) -> PlaneOAuthTokenResponse:
123+
"""Get a new access token using client credentials for bot/app installations."""
124+
data = {
125+
"grant_type": "client_credentials",
126+
"app_installation_id": app_installation_id,
127+
}
128+
129+
headers = {
130+
"Cache-Control": "no-cache",
131+
"Content-Type": "application/x-www-form-urlencoded",
132+
"Authorization": f"Basic {self._get_basic_auth_token()}",
133+
}
134+
135+
try:
136+
response = self.rest_client.post_request(
137+
url=f"{self.base_url}/auth/o/token/", headers=headers, post_params=data
138+
)
139+
140+
response_data = json.loads(response.data.decode("utf-8"))
141+
return PlaneOAuthTokenResponse.validate(response_data)
142+
143+
except ApiException as e:
144+
logger.error(f"Failed to get bot token: {e}")
145+
raise
146+
except Exception as e:
147+
logger.error(f"Unexpected error during bot token request: {e}")
148+
raise ApiException(status=0, reason=str(e))
149+
150+
def get_app_installation(
151+
self, token: str, app_installation_id: str
152+
) -> PlaneOAuthAppInstallation:
153+
"""Get an app installation by ID using the bot token.
154+
For token, use the bot token from the get_bot_token method.
155+
"""
156+
try:
157+
headers = {
158+
"Authorization": f"Bearer {token}",
159+
}
160+
response = self.rest_client.get_request(
161+
url=f"{self.base_url}/auth/o/app-installation/?id={app_installation_id}",
162+
headers=headers,
163+
)
164+
if not response.data:
165+
raise ApiException(status=404, reason="App installation not found")
166+
return PlaneOAuthAppInstallation.validate(json.loads(response.data)[0])
167+
except ApiException as e:
168+
logger.error(f"Failed to get app installation: {e}")
169+
raise
170+
except Exception as e:
171+
logger.error(f"Unexpected error during app installation request: {e}")
172+
173+
def _get_basic_auth_token(self) -> str:
174+
"""Generate basic auth token from client_id and client_secret."""
175+
credentials = f"{self.client_id}:{self.client_secret}"
176+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
177+
return encoded_credentials

plane/oauth/models.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel
4+
5+
6+
class PlaneOAuthTokenResponse(BaseModel):
7+
access_token: str
8+
expires_in: Optional[int] = None
9+
token_type: str = "Bearer"
10+
scope: Optional[str] = None
11+
refresh_token: Optional[str] = None
12+
13+
14+
class WorkspaceDetail(BaseModel):
15+
name: str
16+
slug: str
17+
id: str
18+
logo_url: Optional[str] = None
19+
20+
21+
class PlaneOAuthAppInstallation(BaseModel):
22+
id: str
23+
workspace_detail: WorkspaceDetail
24+
created_at: str
25+
updated_at: str
26+
deleted_at: Optional[str] = None
27+
status: str
28+
created_by: Optional[str] = None
29+
updated_by: Optional[str] = None
30+
workspace: str
31+
application: str
32+
installed_by: str
33+
app_bot: str
34+
webhook: Optional[str] = None

0 commit comments

Comments
 (0)