flask-githubkit 0.1.2__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.
- flask_githubkit/__init__.py +11 -0
- flask_githubkit/auth.py +233 -0
- flask_githubkit/config.py +47 -0
- flask_githubkit/py.typed +0 -0
- flask_githubkit/store.py +92 -0
- flask_githubkit-0.1.2.dist-info/METADATA +259 -0
- flask_githubkit-0.1.2.dist-info/RECORD +10 -0
- flask_githubkit-0.1.2.dist-info/WHEEL +5 -0
- flask_githubkit-0.1.2.dist-info/licenses/LICENSE +21 -0
- flask_githubkit-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Flask extension for GitHub OAuth login, built on GitHubKit."""
|
|
2
|
+
|
|
3
|
+
import flask_githubkit.auth
|
|
4
|
+
import flask_githubkit.config
|
|
5
|
+
import flask_githubkit.store
|
|
6
|
+
|
|
7
|
+
GitHub = flask_githubkit.auth.GitHub
|
|
8
|
+
Config = flask_githubkit.config.Config
|
|
9
|
+
Token = flask_githubkit.store.Token
|
|
10
|
+
|
|
11
|
+
__all__ = ["Config", "GitHub", "Token"]
|
flask_githubkit/auth.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""GitHub OAuth web flow for Flask, backed by GitHubKit."""
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import functools
|
|
5
|
+
import typing
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
import flask
|
|
9
|
+
import githubkit
|
|
10
|
+
import githubkit.auth
|
|
11
|
+
import githubkit.exception
|
|
12
|
+
import httpx
|
|
13
|
+
import werkzeug.routing
|
|
14
|
+
import werkzeug.wrappers
|
|
15
|
+
|
|
16
|
+
import flask_githubkit.config
|
|
17
|
+
import flask_githubkit.store
|
|
18
|
+
|
|
19
|
+
AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
|
|
20
|
+
CLIENT_ATTR = "flask_githubkit_client"
|
|
21
|
+
LOGIN_VIEW = "login"
|
|
22
|
+
LOGIN_MARK = "_flask_githubkit_login"
|
|
23
|
+
|
|
24
|
+
ViewResult = (
|
|
25
|
+
werkzeug.wrappers.Response | tuple[werkzeug.wrappers.Response, int]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitHub:
|
|
30
|
+
"""GitHub OAuth web flow for Flask, backed by GitHubKit.
|
|
31
|
+
|
|
32
|
+
``login`` starts the flow and ``callback`` wraps the callback view,
|
|
33
|
+
passing it the access token (or ``None`` if the user denied
|
|
34
|
+
access). ``client`` returns one GitHub client per request for the
|
|
35
|
+
signed-in user. ``init_app`` sends users to the endpoint named by
|
|
36
|
+
``GITHUB_LOGIN_VIEW`` (default ``login``) when their refresh token
|
|
37
|
+
has expired.
|
|
38
|
+
|
|
39
|
+
``scope`` and ``login_view`` may be given here. When they are not,
|
|
40
|
+
they come from ``GITHUB_SCOPE`` and ``GITHUB_LOGIN_VIEW`` in the
|
|
41
|
+
Flask config. The scope then defaults to ``read:user``. The login
|
|
42
|
+
view defaults to the view decorated with ``@login``, or to
|
|
43
|
+
``login`` if there is none. Tokens are kept by ``store.Token`` in
|
|
44
|
+
``flask.session``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
app: flask.Flask | None = None,
|
|
50
|
+
*,
|
|
51
|
+
scope: str | None = None,
|
|
52
|
+
login_view: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.scope = scope
|
|
55
|
+
self.login_view = login_view
|
|
56
|
+
if app is not None:
|
|
57
|
+
self.init_app(app)
|
|
58
|
+
|
|
59
|
+
def init_app(self, app: flask.Flask) -> None:
|
|
60
|
+
app.register_error_handler(
|
|
61
|
+
githubkit.exception.AuthExpiredError, self._expired
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@typing.overload
|
|
65
|
+
def login(self) -> ViewResult: ...
|
|
66
|
+
|
|
67
|
+
@typing.overload
|
|
68
|
+
def login(
|
|
69
|
+
self, view_func: collections.abc.Callable[..., typing.Any]
|
|
70
|
+
) -> collections.abc.Callable[..., ViewResult]: ...
|
|
71
|
+
|
|
72
|
+
def login(
|
|
73
|
+
self,
|
|
74
|
+
view_func: collections.abc.Callable[..., typing.Any] | None = None,
|
|
75
|
+
) -> ViewResult | collections.abc.Callable[..., ViewResult]:
|
|
76
|
+
"""Start the OAuth flow.
|
|
77
|
+
|
|
78
|
+
Call ``login()`` inside a view, or use ``@github.login`` as a
|
|
79
|
+
decorator. The decorated function is replaced by one that
|
|
80
|
+
redirects to GitHub, and keeps its name for ``url_for``.
|
|
81
|
+
"""
|
|
82
|
+
if view_func is None:
|
|
83
|
+
return self._redirect()
|
|
84
|
+
|
|
85
|
+
@functools.wraps(view_func)
|
|
86
|
+
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> ViewResult:
|
|
87
|
+
return self._redirect()
|
|
88
|
+
|
|
89
|
+
setattr(wrapper, LOGIN_MARK, self)
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
def _redirect(self) -> ViewResult:
|
|
93
|
+
config = self._config()
|
|
94
|
+
if config is None:
|
|
95
|
+
return self._error("GitHub OAuth is not configured.", 500)
|
|
96
|
+
return flask.redirect(self._url(config))
|
|
97
|
+
|
|
98
|
+
def callback(
|
|
99
|
+
self, view_func: collections.abc.Callable[..., ViewResult]
|
|
100
|
+
) -> collections.abc.Callable[..., ViewResult]:
|
|
101
|
+
@functools.wraps(view_func)
|
|
102
|
+
def wrapper(*args: typing.Any, **kwargs: typing.Any) -> ViewResult:
|
|
103
|
+
if not self._check_state():
|
|
104
|
+
return self._error("Invalid OAuth state.", 400)
|
|
105
|
+
|
|
106
|
+
if flask.request.args.get("error"):
|
|
107
|
+
return view_func(None, *args, **kwargs)
|
|
108
|
+
|
|
109
|
+
code = flask.request.args.get("code")
|
|
110
|
+
if not code:
|
|
111
|
+
return self._error(
|
|
112
|
+
"GitHub did not return an authorization code.", 400
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
config = self._config()
|
|
116
|
+
if config is None:
|
|
117
|
+
return self._error("GitHub OAuth is not configured.", 500)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
strategy = self._exchange(code, config)
|
|
121
|
+
except (githubkit.exception.RequestError, httpx.HTTPError):
|
|
122
|
+
return self._error("Could not reach GitHub.", 502)
|
|
123
|
+
flask_githubkit.store.Token.save(strategy)
|
|
124
|
+
return view_func(strategy.token, *args, **kwargs)
|
|
125
|
+
|
|
126
|
+
return wrapper
|
|
127
|
+
|
|
128
|
+
def client(self) -> "githubkit.GitHub[typing.Any] | None":
|
|
129
|
+
"""Return the request's GitHub client, or None if signed out.
|
|
130
|
+
|
|
131
|
+
The client is created once per request, so a token refresh
|
|
132
|
+
happens once and every caller sees the new token. GitHubKit
|
|
133
|
+
refreshes an expired access token itself; the refreshed values
|
|
134
|
+
are saved to the session at the end of the request.
|
|
135
|
+
"""
|
|
136
|
+
clients: dict[GitHub, githubkit.GitHub[typing.Any]] | None = getattr(
|
|
137
|
+
flask.g, CLIENT_ATTR, None
|
|
138
|
+
)
|
|
139
|
+
if clients is None:
|
|
140
|
+
clients = {}
|
|
141
|
+
setattr(flask.g, CLIENT_ATTR, clients)
|
|
142
|
+
|
|
143
|
+
cached = clients.get(self)
|
|
144
|
+
if cached is not None:
|
|
145
|
+
return cached
|
|
146
|
+
|
|
147
|
+
config = self._config()
|
|
148
|
+
if config is None:
|
|
149
|
+
return None
|
|
150
|
+
strategy = flask_githubkit.store.Token.load(config)
|
|
151
|
+
if strategy is None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _save(
|
|
155
|
+
response: werkzeug.wrappers.Response,
|
|
156
|
+
) -> werkzeug.wrappers.Response:
|
|
157
|
+
if flask_githubkit.store.Token.exists():
|
|
158
|
+
flask_githubkit.store.Token.save(strategy)
|
|
159
|
+
return response
|
|
160
|
+
|
|
161
|
+
flask.after_this_request(_save)
|
|
162
|
+
github = githubkit.GitHub(auth=strategy)
|
|
163
|
+
clients[self] = github
|
|
164
|
+
return github
|
|
165
|
+
|
|
166
|
+
def logout(self) -> None:
|
|
167
|
+
flask_githubkit.store.Token.clear()
|
|
168
|
+
flask.g.pop(CLIENT_ATTR, None)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _config() -> flask_githubkit.config.Config | None:
|
|
172
|
+
return flask_githubkit.config.Config.from_current_app()
|
|
173
|
+
|
|
174
|
+
def _url(self, config: flask_githubkit.config.Config) -> str:
|
|
175
|
+
query = urllib.parse.urlencode(
|
|
176
|
+
{
|
|
177
|
+
"client_id": config.client_id,
|
|
178
|
+
"redirect_uri": config.redirect_uri,
|
|
179
|
+
"scope": self.scope or config.scope,
|
|
180
|
+
"state": flask_githubkit.store.State.new(),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
return f"{AUTHORIZE_URL}?{query}"
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _check_state() -> bool:
|
|
187
|
+
received = flask.request.args.get("state", "")
|
|
188
|
+
return flask_githubkit.store.State.check(received)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _exchange(
|
|
192
|
+
code: str,
|
|
193
|
+
config: flask_githubkit.config.Config,
|
|
194
|
+
) -> githubkit.auth.OAuthTokenAuthStrategy:
|
|
195
|
+
app = githubkit.auth.OAuthAppAuthStrategy(
|
|
196
|
+
client_id=config.client_id,
|
|
197
|
+
client_secret=config.client_secret,
|
|
198
|
+
)
|
|
199
|
+
return app.as_web_user(
|
|
200
|
+
code, redirect_uri=config.redirect_uri
|
|
201
|
+
).exchange_token(githubkit.GitHub(auth=app))
|
|
202
|
+
|
|
203
|
+
def _expired(
|
|
204
|
+
self, error: githubkit.exception.AuthExpiredError
|
|
205
|
+
) -> werkzeug.wrappers.Response:
|
|
206
|
+
self.logout()
|
|
207
|
+
view = (
|
|
208
|
+
self.login_view
|
|
209
|
+
or flask.current_app.config.get("GITHUB_LOGIN_VIEW")
|
|
210
|
+
or self._decorated_endpoint()
|
|
211
|
+
or LOGIN_VIEW
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
return flask.redirect(flask.url_for(view))
|
|
215
|
+
except werkzeug.routing.BuildError as build_error:
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
f"GITHUB_LOGIN_VIEW {view!r} is not a Flask endpoint. "
|
|
218
|
+
"Use the endpoint name, such as 'auth.login' for a "
|
|
219
|
+
"blueprint named 'auth'."
|
|
220
|
+
) from build_error
|
|
221
|
+
|
|
222
|
+
def _decorated_endpoint(self) -> str | None:
|
|
223
|
+
"""Return the endpoint of the view decorated with ``@login``."""
|
|
224
|
+
for endpoint, view in flask.current_app.view_functions.items():
|
|
225
|
+
if getattr(view, LOGIN_MARK, None) is self:
|
|
226
|
+
return endpoint
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _error(
|
|
231
|
+
message: str, status: int
|
|
232
|
+
) -> tuple[werkzeug.wrappers.Response, int]:
|
|
233
|
+
return flask.jsonify({"message": message}), status
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OAuth App settings read from the Flask application config."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
5
|
+
import flask
|
|
6
|
+
|
|
7
|
+
SCOPE = "read:user"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass(frozen=True)
|
|
11
|
+
class Config:
|
|
12
|
+
"""OAuth App settings read from the Flask app config.
|
|
13
|
+
|
|
14
|
+
Reads ``GITHUB_CLIENT_ID``, ``GITHUB_CLIENT_SECRET`` and
|
|
15
|
+
``GITHUB_REDIRECT_URI``. ``SECRET_KEY`` must also be set, because
|
|
16
|
+
the OAuth state and tokens are kept in ``flask.session``.
|
|
17
|
+
``GITHUB_SCOPE`` is optional: the space-separated OAuth scope to
|
|
18
|
+
request, ``read:user`` by default.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
client_id: str
|
|
22
|
+
client_secret: str
|
|
23
|
+
redirect_uri: str
|
|
24
|
+
scope: str = SCOPE
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def from_current_app() -> "Config | None":
|
|
28
|
+
app_config = flask.current_app.config
|
|
29
|
+
client_id = app_config.get("GITHUB_CLIENT_ID")
|
|
30
|
+
client_secret = app_config.get("GITHUB_CLIENT_SECRET")
|
|
31
|
+
redirect_uri = app_config.get("GITHUB_REDIRECT_URI")
|
|
32
|
+
scope = app_config.get("GITHUB_SCOPE", SCOPE)
|
|
33
|
+
secret_key = app_config.get("SECRET_KEY")
|
|
34
|
+
if not all(
|
|
35
|
+
isinstance(value, str) and value
|
|
36
|
+
for value in (
|
|
37
|
+
client_id,
|
|
38
|
+
client_secret,
|
|
39
|
+
redirect_uri,
|
|
40
|
+
scope,
|
|
41
|
+
secret_key,
|
|
42
|
+
)
|
|
43
|
+
):
|
|
44
|
+
return None
|
|
45
|
+
return Config(
|
|
46
|
+
str(client_id), str(client_secret), str(redirect_uri), str(scope)
|
|
47
|
+
)
|
flask_githubkit/py.typed
ADDED
|
File without changes
|
flask_githubkit/store.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Everything flask_githubkit keeps in the Flask session."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import hmac
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
import flask
|
|
8
|
+
import githubkit.auth
|
|
9
|
+
import githubkit.exception
|
|
10
|
+
|
|
11
|
+
import flask_githubkit.config
|
|
12
|
+
|
|
13
|
+
TOKEN_KEY = "flask_githubkit.token"
|
|
14
|
+
STATE_KEY = "flask_githubkit.state"
|
|
15
|
+
DATETIME_KEYS = ("expire_time", "refresh_token_expire_time")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Token:
|
|
19
|
+
"""Keeps a user's GitHub tokens in ``flask.session``.
|
|
20
|
+
|
|
21
|
+
With a server-side session backend such as Redis, the browser only
|
|
22
|
+
holds the session id. Sessions are marked permanent, so
|
|
23
|
+
``PERMANENT_SESSION_LIFETIME`` is their expiry and each save resets
|
|
24
|
+
it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def save(strategy: githubkit.auth.OAuthTokenAuthStrategy) -> None:
|
|
29
|
+
payload: dict[str, str | None] = {
|
|
30
|
+
"token": strategy.token,
|
|
31
|
+
"refresh_token": strategy.refresh_token,
|
|
32
|
+
}
|
|
33
|
+
for key in DATETIME_KEYS:
|
|
34
|
+
value = getattr(strategy, key)
|
|
35
|
+
payload[key] = value.isoformat() if value else None
|
|
36
|
+
|
|
37
|
+
flask.session.permanent = True
|
|
38
|
+
flask.session[TOKEN_KEY] = payload
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def load(
|
|
42
|
+
config: flask_githubkit.config.Config,
|
|
43
|
+
) -> githubkit.auth.OAuthTokenAuthStrategy | None:
|
|
44
|
+
payload = flask.session.get(TOKEN_KEY)
|
|
45
|
+
if not isinstance(payload, dict):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
return githubkit.auth.OAuthTokenAuthStrategy(
|
|
50
|
+
client_id=config.client_id,
|
|
51
|
+
client_secret=config.client_secret,
|
|
52
|
+
token=payload.get("token"),
|
|
53
|
+
refresh_token=payload.get("refresh_token"),
|
|
54
|
+
**{
|
|
55
|
+
key: datetime.datetime.fromisoformat(payload[key])
|
|
56
|
+
if payload.get(key)
|
|
57
|
+
else None
|
|
58
|
+
for key in DATETIME_KEYS
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
except (
|
|
62
|
+
githubkit.exception.AuthCredentialError,
|
|
63
|
+
TypeError,
|
|
64
|
+
ValueError,
|
|
65
|
+
):
|
|
66
|
+
Token.clear()
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def clear() -> None:
|
|
71
|
+
flask.session.pop(TOKEN_KEY, None)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def exists() -> bool:
|
|
75
|
+
return TOKEN_KEY in flask.session
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class State:
|
|
79
|
+
"""Keeps the one-time OAuth ``state`` value in ``flask.session``."""
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def new() -> str:
|
|
83
|
+
state = secrets.token_urlsafe(32)
|
|
84
|
+
flask.session[STATE_KEY] = state
|
|
85
|
+
return state
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def check(received: str) -> bool:
|
|
89
|
+
expected = flask.session.pop(STATE_KEY, None)
|
|
90
|
+
return isinstance(expected, str) and hmac.compare_digest(
|
|
91
|
+
expected, received
|
|
92
|
+
)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flask-githubkit
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Flask extension for GitHub OAuth login, built on GitHubKit. Handles expiring-token refresh and server-side sessions.
|
|
5
|
+
Author: LJFCoding
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/LJFCoding/flask-githubkit
|
|
8
|
+
Project-URL: Repository, https://github.com/LJFCoding/flask-githubkit
|
|
9
|
+
Project-URL: Issues, https://github.com/LJFCoding/flask-githubkit/issues
|
|
10
|
+
Keywords: flask,github,githubkit,oauth,authentication
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: Flask>=3.0
|
|
22
|
+
Requires-Dist: githubkit<1.0,>=0.16.1
|
|
23
|
+
Requires-Dist: httpx<1.0,>=0.23
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest; extra == "test"
|
|
26
|
+
Requires-Dist: coverage; extra == "test"
|
|
27
|
+
Provides-Extra: lint
|
|
28
|
+
Requires-Dist: ruff; extra == "lint"
|
|
29
|
+
Requires-Dist: mypy; extra == "lint"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# flask-githubkit
|
|
33
|
+
|
|
34
|
+
A Flask extension for GitHub OAuth login, built on
|
|
35
|
+
[GitHubKit](https://github.com/yanyongyu/githubkit). It handles expiring-token
|
|
36
|
+
refresh and stores tokens in `flask.session`.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Install the release directly from GitHub:
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
pip install "flask-githubkit @ git+https://github.com/LJFCoding/flask-githubkit.git@v0.1.2"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
It is not published on PyPI yet, so `pip install flask-githubkit` will not
|
|
47
|
+
find it. It requires Python 3.10+ and installs Flask, GitHubKit and httpx.
|
|
48
|
+
|
|
49
|
+
For local development, install the test dependencies with
|
|
50
|
+
`pip install -e ".[test]"`.
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
1. `github.login()` redirects the user to GitHub with a random `state` value
|
|
55
|
+
that is kept in `flask.session`.
|
|
56
|
+
2. GitHub redirects back to your callback URL with a `code` and the `state`.
|
|
57
|
+
3. `@github.callback` checks the `state` (blocking login CSRF), exchanges the
|
|
58
|
+
`code` for tokens through GitHubKit, saves them in `flask.session`, and
|
|
59
|
+
calls your view with the access token.
|
|
60
|
+
4. In any later request, `github.client()` returns a GitHubKit client for that
|
|
61
|
+
user. GitHubKit refreshes an expired access token itself, and the new
|
|
62
|
+
token values are saved back to the session when the request finishes.
|
|
63
|
+
|
|
64
|
+
The names follow [Flask-GitHub](https://github.com/cenkalti/github-flask), but
|
|
65
|
+
the API is smaller and the HTTP calls are made by
|
|
66
|
+
[GitHubKit](https://github.com/yanyongyu/githubkit), so you get its typed
|
|
67
|
+
`rest` methods instead of raw `get`/`post` helpers. Only synchronous Flask
|
|
68
|
+
apps are supported.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
Register a GitHub OAuth App and set its callback URL to the value of
|
|
73
|
+
`GITHUB_REDIRECT_URI`. Enable **Expire user authorization tokens** in the
|
|
74
|
+
OAuth App settings if you want GitHub to issue refresh tokens.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import flask
|
|
78
|
+
import flask_githubkit
|
|
79
|
+
|
|
80
|
+
app = flask.Flask(__name__)
|
|
81
|
+
app.config.update(
|
|
82
|
+
SECRET_KEY="load-this-from-a-secret-manager",
|
|
83
|
+
GITHUB_CLIENT_ID="your-client-id",
|
|
84
|
+
GITHUB_CLIENT_SECRET="load-this-from-a-secret-manager",
|
|
85
|
+
GITHUB_REDIRECT_URI="https://example.com/callback",
|
|
86
|
+
GITHUB_SCOPE="read:user",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
github = flask_githubkit.GitHub(app)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.route("/login")
|
|
93
|
+
@github.login
|
|
94
|
+
def login():
|
|
95
|
+
"""Replaced by a redirect to GitHub."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.route("/callback")
|
|
99
|
+
@github.callback
|
|
100
|
+
def callback(access_token):
|
|
101
|
+
if access_token is None:
|
|
102
|
+
return flask.jsonify(
|
|
103
|
+
{"message": "GitHub authorization was denied"}
|
|
104
|
+
), 401
|
|
105
|
+
return flask.jsonify({"message": "Signed in"})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.route("/me")
|
|
109
|
+
def me():
|
|
110
|
+
api = github.client()
|
|
111
|
+
if api is None:
|
|
112
|
+
return flask.redirect(flask.url_for("login"))
|
|
113
|
+
user = api.rest.users.get_authenticated().parsed_data
|
|
114
|
+
return flask.jsonify({"login": user.login})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`callback` validates the OAuth `state`, exchanges the authorization
|
|
118
|
+
code, stores the token data, and calls the decorated view with the access token.
|
|
119
|
+
If the user denies access, the view receives `None`. Protect callback handling
|
|
120
|
+
with the decorator as shown; it is responsible for validating `state`.
|
|
121
|
+
|
|
122
|
+
With an application factory, create the extension once and initialize it for
|
|
123
|
+
each app:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
github = flask_githubkit.GitHub()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_app():
|
|
130
|
+
app = flask.Flask(__name__)
|
|
131
|
+
app.config.from_mapping(
|
|
132
|
+
SECRET_KEY="load-this-from-a-secret-manager",
|
|
133
|
+
GITHUB_CLIENT_ID="your-client-id",
|
|
134
|
+
GITHUB_CLIENT_SECRET="load-this-from-a-secret-manager",
|
|
135
|
+
GITHUB_REDIRECT_URI="https://example.com/callback",
|
|
136
|
+
GITHUB_SCOPE="read:user",
|
|
137
|
+
)
|
|
138
|
+
github.init_app(app)
|
|
139
|
+
return app
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Examples
|
|
143
|
+
|
|
144
|
+
Each file in [examples/](examples) is a complete app. They are checked by the
|
|
145
|
+
test suite, which confirms the login route redirects to GitHub.
|
|
146
|
+
|
|
147
|
+
| File | Shows |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| [single_file.py](examples/single_file.py) | A module-level app with `GitHub(app)` |
|
|
150
|
+
| [factory.py](examples/factory.py) | An app factory with `init_app` and the optional config keys |
|
|
151
|
+
| [blueprint.py](examples/blueprint.py) | Login routes in a blueprint; the `auth.login` endpoint is found from `@github.login` |
|
|
152
|
+
| [routes.py](examples/routes.py) | A `login_required` decorator and a route that calls the GitHub API |
|
|
153
|
+
|
|
154
|
+
Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `SECRET_KEY` in your
|
|
155
|
+
environment, then run one with, for example,
|
|
156
|
+
`flask --app examples/single_file run`.
|
|
157
|
+
|
|
158
|
+
Calling the API goes through GitHubKit, so see its
|
|
159
|
+
[documentation](https://yanyongyu.github.io/githubkit/) for everything
|
|
160
|
+
`client().rest` offers.
|
|
161
|
+
|
|
162
|
+
## Coming from Flask-GitHub
|
|
163
|
+
|
|
164
|
+
| Flask-GitHub | flask-githubkit |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `GitHub(app)` | `GitHub(app)` or `init_app(app)` |
|
|
167
|
+
| `github.authorize()` | `@github.login`, or `github.login()` in a view |
|
|
168
|
+
| `@github.authorized_handler` | `@github.callback` |
|
|
169
|
+
| `@github.access_token_getter` | Not needed: tokens are kept in `flask.session` |
|
|
170
|
+
| `github.get("user")` | `github.client().rest.users.get_authenticated()` |
|
|
171
|
+
| `authorize(scope=..., redirect_uri=...)` | Not supported: use `scope=` in `GitHub(...)` or `GITHUB_SCOPE`, and `GITHUB_REDIRECT_URI` |
|
|
172
|
+
|
|
173
|
+
## Configuration and API
|
|
174
|
+
|
|
175
|
+
| Flask config key | Required | Purpose |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth App client ID |
|
|
178
|
+
| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth App client secret |
|
|
179
|
+
| `GITHUB_REDIRECT_URI` | Yes | Callback URL registered with the OAuth App |
|
|
180
|
+
| `SECRET_KEY` | Yes | Flask session signing key |
|
|
181
|
+
| `GITHUB_SCOPE` | No | Space-separated OAuth scopes to request. Default `read:user`. |
|
|
182
|
+
| `GITHUB_LOGIN_VIEW` | No | Endpoint name users are sent to when their refresh token has expired. Usually not needed: see below. It is an endpoint name such as `auth.login`, not a URL. |
|
|
183
|
+
|
|
184
|
+
`flask_githubkit.GitHub(app=None, *, scope=None, login_view=None)` accepts an
|
|
185
|
+
optional Flask app. Use `init_app(app)` to initialize it later.
|
|
186
|
+
|
|
187
|
+
`scope` and `login_view` are optional. Each one is taken from the constructor
|
|
188
|
+
if you pass it, otherwise from `GITHUB_SCOPE` or `GITHUB_LOGIN_VIEW` in the
|
|
189
|
+
Flask config. If neither is set, the scope is `read:user`. The login view is
|
|
190
|
+
the view you decorated with `@github.login`, with its blueprint prefix
|
|
191
|
+
(`auth.login`), or an endpoint named `login` if there is no decorated view.
|
|
192
|
+
The client ID, client secret, redirect URI and `SECRET_KEY` always come from
|
|
193
|
+
the Flask config.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
github = flask_githubkit.GitHub(
|
|
197
|
+
scope="read:user repo", login_view="auth.login"
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- `@github.login` turns a view into a redirect to GitHub that starts the
|
|
202
|
+
OAuth flow. You can also call `github.login()` inside a view and return
|
|
203
|
+
the result, which is useful if you want to run code first. The decorated
|
|
204
|
+
function's body never runs.
|
|
205
|
+
- `@callback` wraps the callback view.
|
|
206
|
+
- `client()` returns the request-cached GitHubKit `GitHub` client, or `None`
|
|
207
|
+
when there is no valid stored token.
|
|
208
|
+
- `logout()` removes the stored GitHub token from the session.
|
|
209
|
+
|
|
210
|
+
The extension registers an `AuthExpiredError` handler that clears the stored
|
|
211
|
+
token and redirects to the login view. GitHubKit also raises
|
|
212
|
+
`AuthExpiredError` when GitHub rejects the authorization code, so a stale or
|
|
213
|
+
reused code sends the user back to login. If GitHub cannot be reached while
|
|
214
|
+
exchanging the code (a timeout, a connection error or a 5xx response), the
|
|
215
|
+
callback returns a `502` JSON error, `Could not reach GitHub.`, and does not
|
|
216
|
+
call your view. If the login view is not a real
|
|
217
|
+
endpoint, the handler raises a `RuntimeError` that says so.
|
|
218
|
+
|
|
219
|
+
`flask_githubkit.Config.from_current_app()` reads the configuration keys above.
|
|
220
|
+
`flask_githubkit.Token` serializes token expiry datetimes as ISO-formatted
|
|
221
|
+
strings in `flask.session`.
|
|
222
|
+
|
|
223
|
+
## Sessions, token refresh, and limitations
|
|
224
|
+
|
|
225
|
+
The default Flask session is a signed, client-side cookie; it is **not
|
|
226
|
+
encrypted**, so users can read its contents. This package stores OAuth token
|
|
227
|
+
data there, so use a server-side session backend such as Flask-Session with
|
|
228
|
+
Redis in production. The extension only uses `flask.session` and marks the
|
|
229
|
+
session permanent when it saves tokens. Configure your backend and
|
|
230
|
+
`PERMANENT_SESSION_LIFETIME` (for example, 30 days) in your application; sliding
|
|
231
|
+
expiry and TTL behavior depend on that backend and are not provided by this
|
|
232
|
+
package.
|
|
233
|
+
|
|
234
|
+
GitHubKit refreshes expired access tokens when the client is used. The extension
|
|
235
|
+
writes updated token values back to the session after the request. GitHub
|
|
236
|
+
refresh tokens are single-use, so concurrent requests can race while refreshing;
|
|
237
|
+
this package does not provide a distributed lock. Errors raised while your own
|
|
238
|
+
code uses `client()` (for example a network failure during an API call) are not
|
|
239
|
+
caught by the extension.
|
|
240
|
+
|
|
241
|
+
This package supports synchronous Flask apps and GitHub OAuth Apps. It does not
|
|
242
|
+
support GitHub Apps or configure GitHub Enterprise endpoints. Real GitHub code
|
|
243
|
+
exchange and refresh, Redis operation, and Flask-Session TTL behavior require
|
|
244
|
+
integration testing against those services and are not verified by the
|
|
245
|
+
automated tests in this repository.
|
|
246
|
+
|
|
247
|
+
## Tests
|
|
248
|
+
|
|
249
|
+
```console
|
|
250
|
+
pip install -e ".[test]"
|
|
251
|
+
pytest
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
CI also requires 100% line and branch coverage:
|
|
255
|
+
|
|
256
|
+
```console
|
|
257
|
+
coverage run -m pytest
|
|
258
|
+
coverage report
|
|
259
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flask_githubkit/__init__.py,sha256=AZt9expycUWgbxbKHIWtjnLHId-5ML13tepLQAeX3MU,308
|
|
2
|
+
flask_githubkit/auth.py,sha256=fDO-oSCFEywqNQLh_w_11qHtvPwJlhjy2kXGc7iprkE,7919
|
|
3
|
+
flask_githubkit/config.py,sha256=28OOjlIpBjHAMOsisu1sJcl7XRmkUesqAi9LAfrVgiQ,1414
|
|
4
|
+
flask_githubkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
flask_githubkit/store.py,sha256=2PzbYa8-dMD5_vHTZDjxb0jhClWRNVc3qVk84lbnHHw,2645
|
|
6
|
+
flask_githubkit-0.1.2.dist-info/licenses/LICENSE,sha256=M7lCXlVXCyRAtQabbdCdtyn9PNxUSHCYi-fAz3jUmyI,1066
|
|
7
|
+
flask_githubkit-0.1.2.dist-info/METADATA,sha256=demhXsk-LceMVsl2ayaNpKi3ZJfCNUQpJs2rLUt7yyE,10269
|
|
8
|
+
flask_githubkit-0.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
flask_githubkit-0.1.2.dist-info/top_level.txt,sha256=lDJ7tJYO7dwMWR59OaihDiG7Hm3WG4MYYUiko-aeHS8,16
|
|
10
|
+
flask_githubkit-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LJFCoding
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flask_githubkit
|