Skip to content

Commit f291a36

Browse files
authored
Implement AppUserAuth for Github App user tokens (#2546)
Allows to refresh Github App user token. Integrates `ApplicationOAuth` into `github.Auth`.
1 parent 0384e2f commit f291a36

17 files changed

+621
-34
lines changed

doc/examples/Authentication.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,33 @@ expiration timeout. The access token is refreshed automatically.
6565
>>> g = Github(auth=auth)
6666
>>> g.get_repo("user/repo").name
6767
'repo'
68+
69+
App user authentication
70+
-----------------------
71+
72+
A Github App can authenticate on behalf of a user. For this, the user has to `generate a user access token for a Github App <https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user>`__.
73+
This process completes with a one-time ``code``. Together with the ``client_id`` and ``client_secret`` of the app,
74+
a Github App user token can be generated once:
75+
76+
>>> g = Github()
77+
>>> app = g.get_oauth_application(client_id, client_secret)
78+
>>> token = app.get_access_token(code)
79+
80+
Memorize the ``token.refresh_token``, as only this can be used to create new tokens for this user.
81+
The ``token.token`` expires 8 hours, and the ``token.refresh_token`` expires 6 months after creation.
82+
83+
A token can be refreshed as follows. This invalidates the old token and old refresh token, and creates
84+
a new set of token and refresh tokens:
85+
86+
>>> g = Github()
87+
>>> app = g.get_oauth_application(client_id, client_secret)
88+
>>> token = app.refresh_access_token(refresh_token)
89+
90+
You can authenticate with Github using this token:
91+
92+
>>> auth = app.get_app_user_auth(token)
93+
>>> g = Github(auth=auth)
94+
>>> g.get_user().login
95+
'user_login'
96+
97+
The ``auth`` instance will refresh the token automatically when ``auth.token`` is accessed.

github/AccessToken.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
# #
2121
################################################################################
2222

23+
from datetime import datetime, timedelta
24+
2325
import github.GithubObject
2426

2527

@@ -34,6 +36,11 @@ def __repr__(self):
3436
"token": f"{self.token[:5]}...",
3537
"scope": self.scope,
3638
"type": self.type,
39+
"expires_in": self.expires_in,
40+
"refresh_token": (
41+
f"{self.refresh_token[:5]}..." if self.refresh_token else None
42+
),
43+
"refresh_token_expires_in": self.refresh_expires_in,
3744
}
3845
)
3946

@@ -58,15 +65,81 @@ def scope(self):
5865
"""
5966
return self._scope.value
6067

68+
@property
69+
def created(self):
70+
"""
71+
:type: datetime
72+
"""
73+
return self._created
74+
75+
@property
76+
def expires_in(self):
77+
"""
78+
:type: Optional[int]
79+
"""
80+
if self._expires_in is not github.GithubObject.NotSet:
81+
return self._expires_in.value
82+
return None
83+
84+
@property
85+
def expires_at(self):
86+
"""
87+
:type: Optional[datetime]
88+
"""
89+
seconds = self.expires_in
90+
if seconds is not None:
91+
return self._created + timedelta(seconds=seconds)
92+
return None
93+
94+
@property
95+
def refresh_token(self):
96+
"""
97+
:type: Optional[string]
98+
"""
99+
if self._refresh_token is not github.GithubObject.NotSet:
100+
return self._refresh_token.value
101+
return None
102+
103+
@property
104+
def refresh_expires_in(self):
105+
"""
106+
:type: Optional[int]
107+
"""
108+
if self._refresh_expires_in is not github.GithubObject.NotSet:
109+
return self._refresh_expires_in.value
110+
return None
111+
112+
@property
113+
def refresh_expires_at(self):
114+
"""
115+
:type: Optional[datetime]
116+
"""
117+
seconds = self.refresh_expires_in
118+
if seconds is not None:
119+
return self._created + timedelta(seconds=seconds)
120+
return None
121+
61122
def _initAttributes(self):
62123
self._token = github.GithubObject.NotSet
63124
self._type = github.GithubObject.NotSet
64125
self._scope = github.GithubObject.NotSet
126+
self._expires_in = github.GithubObject.NotSet
127+
self._refresh_token = github.GithubObject.NotSet
128+
self._refresh_expires_in = github.GithubObject.NotSet
65129

66130
def _useAttributes(self, attributes):
131+
self._created = datetime.utcnow()
67132
if "access_token" in attributes: # pragma no branch
68133
self._token = self._makeStringAttribute(attributes["access_token"])
69134
if "token_type" in attributes: # pragma no branch
70135
self._type = self._makeStringAttribute(attributes["token_type"])
71136
if "scope" in attributes: # pragma no branch
72137
self._scope = self._makeStringAttribute(attributes["scope"])
138+
if "expires_in" in attributes: # pragma no branch
139+
self._expires_in = self._makeIntAttribute(attributes["expires_in"])
140+
if "refresh_token" in attributes: # pragma no branch
141+
self._refresh_token = self._makeStringAttribute(attributes["refresh_token"])
142+
if "refresh_token_expires_in" in attributes: # pragma no branch
143+
self._refresh_expires_in = self._makeIntAttribute(
144+
attributes["refresh_token_expires_in"]
145+
)

github/AccessToken.pyi

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from typing import Any, Dict
1+
import datetime
2+
from typing import Any, Dict, Optional
23

4+
from github.Auth import AppUserAuth
35
from github.GithubObject import NonCompletableGithubObject
46

57
class AccessToken(NonCompletableGithubObject):
@@ -12,3 +14,16 @@ class AccessToken(NonCompletableGithubObject):
1214
def type(self) -> str: ...
1315
@property
1416
def scope(self) -> str: ...
17+
@property
18+
def created(self) -> datetime.datetime: ...
19+
@property
20+
def expires_in(self) -> Optional[int]: ...
21+
@property
22+
def expires_at(self) -> Optional[datetime.datetime]: ...
23+
@property
24+
def refresh_token(self) -> Optional[str]: ...
25+
@property
26+
def refresh_expires_in(self) -> Optional[int]: ...
27+
@property
28+
def refresh_expires_at(self) -> Optional[datetime.datetime]: ...
29+
def as_app_user_auth(self) -> AppUserAuth: ...

github/ApplicationOAuth.py

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
############################ Copyrights and license ###########################
22
# #
33
# Copyright 2019 Rigas Papathanasopoulos <[email protected]> #
4+
# Copyright 2023 Enrico Minack <[email protected]> #
45
# #
56
# This file is part of PyGithub. #
67
# http://pygithub.readthedocs.io/ #
@@ -96,21 +97,74 @@ def get_access_token(self, code, state=None):
9697
if state is not None:
9798
post_parameters["state"] = state
9899

99-
headers, data = self._requester.requestJsonAndCheck(
100-
"POST",
101-
"https://github.com/login/oauth/access_token",
102-
headers={
103-
"Accept": "application/json",
104-
"Content-Type": "application/json",
105-
"User-Agent": "PyGithub/Python",
106-
},
107-
input=post_parameters,
100+
headers, data = self._checkError(
101+
*self._requester.requestJsonAndCheck(
102+
"POST",
103+
"https://github.com/login/oauth/access_token",
104+
headers={"Accept": "application/json"},
105+
input=post_parameters,
106+
)
108107
)
109108

110109
return AccessToken(
111110
requester=self._requester,
112-
# not required, this is a NonCompletableGithubObject
113-
headers={},
111+
headers=headers,
114112
attributes=data,
115113
completed=False,
116114
)
115+
116+
def get_app_user_auth(self, token):
117+
"""
118+
:param token: AccessToken
119+
"""
120+
# imported here to avoid circular import
121+
from github.Auth import AppUserAuth
122+
123+
return AppUserAuth(
124+
client_id=self.client_id,
125+
client_secret=self.client_secret,
126+
token=token.token,
127+
token_type=token.type,
128+
expires_at=token.expires_at,
129+
refresh_token=token.refresh_token,
130+
refresh_expires_at=token.refresh_expires_at,
131+
requester=self._requester,
132+
)
133+
134+
def refresh_access_token(self, refresh_token):
135+
"""
136+
:calls: `POST /login/oauth/access_token <https://docs.github.com/en/developers/apps/identifying-and-authorizing-users-for-github-apps>`_
137+
:param refresh_token: string
138+
"""
139+
assert isinstance(refresh_token, str)
140+
post_parameters = {
141+
"client_id": self.client_id,
142+
"client_secret": self.client_secret,
143+
"grant_type": "refresh_token",
144+
"refresh_token": refresh_token,
145+
}
146+
147+
headers, data = self._checkError(
148+
*self._requester.requestJsonAndCheck(
149+
"POST",
150+
"https://github.com/login/oauth/access_token",
151+
headers={"Accept": "application/json"},
152+
input=post_parameters,
153+
)
154+
)
155+
156+
return AccessToken(
157+
requester=self._requester,
158+
headers=headers,
159+
attributes=data,
160+
completed=False,
161+
)
162+
163+
@staticmethod
164+
def _checkError(headers, data):
165+
if isinstance(data, dict) and "error" in data:
166+
if data["error"] == "bad_verification_code":
167+
raise github.BadCredentialsException(200, data, headers)
168+
raise github.GithubException(200, data, headers)
169+
170+
return headers, data

github/ApplicationOAuth.pyi

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from typing import Any, Dict, Optional
1+
from typing import Any, Dict, Optional, Tuple
22

33
from github.AccessToken import AccessToken
44
from github.GithubObject import NonCompletableGithubObject
5-
from github.Requester import Requester
5+
from github.Auth import AppUserAuth
66

77
class ApplicationOAuth(NonCompletableGithubObject):
88
def __repr__(self) -> str: ...
@@ -13,6 +13,17 @@ class ApplicationOAuth(NonCompletableGithubObject):
1313
@property
1414
def client_secret(self) -> str: ...
1515
def get_login_url(
16-
self, redirect_uri: Optional[str], state: Optional[str], login: Optional[str]
16+
self,
17+
redirect_uri: Optional[str] = ...,
18+
state: Optional[str] = ...,
19+
login: Optional[str] = ...,
1720
) -> str: ...
18-
def get_access_token(self, code: str, state: Optional[str]) -> AccessToken: ...
21+
def get_access_token(
22+
self, code: str, state: Optional[str] = ...
23+
) -> AccessToken: ...
24+
def get_app_user_auth(self, token: AccessToken) -> AppUserAuth: ...
25+
def refresh_access_token(self, refresh_token: str) -> AccessToken: ...
26+
@staticmethod
27+
def _checkError(
28+
header: Dict[str, Any], data: Optional[Dict[str, Any]]
29+
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: ...

0 commit comments

Comments
 (0)