Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.1.6

- `GITHUB_REDIRECT_URI` is now optional. When unset, the callback URL is
built from the `@github.callback` route and the current host. The
endpoint is found once and kept in `GITHUB_CALLBACK_VIEW`.
- `Config.redirect_uri` is now `None` when `GITHUB_REDIRECT_URI` is unset.
- Add a PyPI install link to the docs home page.
- Document behind-a-proxy setup with `ProxyFix`.
- Drop `GITHUB_REDIRECT_URI` from the examples and quick start.

## 0.1.5

- Link the docs and README to the PyPI project page.
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ pip install flask-githubkit
To pin a release or install directly from GitHub:

```console
pip install "flask-githubkit==0.1.5"
pip install "flask-githubkit @ git+https://github.com/LJFCoding/flask-githubkit.git@v0.1.5"
pip install "flask-githubkit==0.1.6"
pip install "flask-githubkit @ git+https://github.com/LJFCoding/flask-githubkit.git@v0.1.6"
```

It requires Python 3.10+ and installs Flask, GitHubKit and httpx.
Expand Down Expand Up @@ -47,8 +47,9 @@ apps are supported.

## Quick start

Register a GitHub OAuth App and set its callback URL to the value of
`GITHUB_REDIRECT_URI`. Enable **Expire user authorization tokens** in the
Register a GitHub OAuth App and set its callback URL to your
`@github.callback` route, for example `https://example.com/callback`.
Enable **Expire user authorization tokens** in the
OAuth App settings if you want GitHub to issue refresh tokens.

```python
Expand All @@ -60,7 +61,6 @@ app.config.update(
SECRET_KEY="load-this-from-a-secret-manager",
GITHUB_CLIENT_ID="your-client-id",
GITHUB_CLIENT_SECRET="load-this-from-a-secret-manager",
GITHUB_REDIRECT_URI="https://example.com/callback",
GITHUB_SCOPE="read:user",
)

Expand Down Expand Up @@ -110,7 +110,6 @@ def create_app():
SECRET_KEY="load-this-from-a-secret-manager",
GITHUB_CLIENT_ID="your-client-id",
GITHUB_CLIENT_SECRET="load-this-from-a-secret-manager",
GITHUB_REDIRECT_URI="https://example.com/callback",
GITHUB_SCOPE="read:user",
)
github.init_app(app)
Expand Down Expand Up @@ -154,7 +153,7 @@ Calling the API goes through GitHubKit, so see its
| --- | --- | --- |
| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth App client ID |
| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth App client secret |
| `GITHUB_REDIRECT_URI` | Yes | Callback URL registered with the OAuth App |
| `GITHUB_REDIRECT_URI` | No | Callback URL registered with the OAuth App. Default: the `@github.callback` route on the current host. |
| `SECRET_KEY` | Yes | Flask session signing key |
| `GITHUB_SCOPE` | No | Space-separated OAuth scopes to request. Default `read:user`. |
| `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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

project = "flask-githubkit"
author = "LJFCoding"
release = "0.1.3"
release = "0.1.6"

extensions = [
"myst_parser",
Expand Down
34 changes: 31 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,43 @@ config. Configure the app before serving requests.
| `SECRET_KEY` | Yes | — | Flask session signing key. Keep it secret and stable. |
| `GITHUB_CLIENT_ID` | Yes | — | Client ID from your GitHub OAuth App. |
| `GITHUB_CLIENT_SECRET` | Yes | — | Client secret from your GitHub OAuth App. |
| `GITHUB_REDIRECT_URI` | Yes | — | Absolute callback URL registered with the OAuth App. |
| `GITHUB_REDIRECT_URI` | No | — | Absolute callback URL registered with the OAuth App. Defaults to the `@github.callback` route on the current host. Set it behind a proxy that hides the public host. |
| `GITHUB_SCOPE` | No | `read:user` | Space-separated OAuth scopes requested at login. |
| `GITHUB_LOGIN_VIEW` | No | Decorated login view, otherwise `login` | Flask endpoint to use when a refresh token has expired. |

The client ID, client secret, redirect URI, scope and `SECRET_KEY` must be
non-empty strings. If any is missing or empty, the login route returns a
The client ID, client secret, scope and `SECRET_KEY` must be non-empty
strings. If any is missing or empty, the login route returns a
JSON error with HTTP 500, the callback route returns the same error once its
`state` check passes, and `github.client()` returns `None`.

## Callback URL

You do not need to set `GITHUB_REDIRECT_URI`. When it is unset, the
extension finds the route decorated with `@github.callback` and builds its
URL from the current request's host. The endpoint is found once, on the
first request, and kept in `GITHUB_CALLBACK_VIEW`. If no route is decorated
and `GITHUB_REDIRECT_URI` is unset, the login route raises a `RuntimeError`.

The URL must match the callback URL registered with the OAuth App.

### Behind a proxy

Flask builds the host from the incoming request. Behind a reverse proxy
that terminates HTTPS or rewrites the host, Flask may see
`http://127.0.0.1:8000` instead of your public address, and GitHub rejects
the callback with a `redirect_uri` mismatch. Tell Flask to trust the
proxy's forwarded headers:

```python
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
```

Only do this when a proxy you control sets those headers, because clients
can forge them otherwise. Alternatively, set `GITHUB_REDIRECT_URI` to the
public callback URL, which is then used as-is.

## Initialize the extension

For a single app, pass the app to the extension:
Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ myst:

**GitHub OAuth for Flask, with GitHubKit's typed API client.**

Install it from [PyPI](https://pypi.org/project/flask-githubkit/) with
`python -m pip install flask-githubkit`.

`flask-githubkit` connects Flask routes to GitHub's OAuth flow and API. It
validates the OAuth callback's `state`, stores user tokens in the Flask
session, and saves refreshed token values after API calls.
Expand Down
10 changes: 6 additions & 4 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ python -m pip install flask-githubkit
To pin the version used by your project:

```console
python -m pip install "flask-githubkit==0.1.5"
python -m pip install "flask-githubkit==0.1.6"
```

You can also install a release directly from GitHub:

```console
python -m pip install \
"flask-githubkit @ git+https://github.com/LJFCoding/flask-githubkit.git@v0.1.5"
"flask-githubkit @ git+https://github.com/LJFCoding/flask-githubkit.git@v0.1.6"
```

The package installs Flask, GitHubKit and httpx as dependencies.
Expand All @@ -39,8 +39,10 @@ For example:
| `/callback` | `http://localhost:5000/callback` |
| Blueprint route `/auth/callback` | `http://localhost:5000/auth/callback` |

The callback URL must match the `GITHUB_REDIRECT_URI` value used by your
application. For production, use your application's HTTPS URL.
The callback URL must match the URL your application uses. By default
that is the `@github.callback` route on the current host, or
`GITHUB_REDIRECT_URI` if you set it. For production, use your application's
HTTPS URL.

Copy the OAuth App's **Client ID** and **Client secret** into your local
environment or secrets manager. Never commit the client secret to Git.
Expand Down
5 changes: 2 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ app.config.from_mapping(
SECRET_KEY=os.environ["SECRET_KEY"],
GITHUB_CLIENT_ID=os.environ["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET=os.environ["GITHUB_CLIENT_SECRET"],
GITHUB_REDIRECT_URI="http://localhost:5000/callback",
GITHUB_SCOPE="read:user",
)

Expand Down Expand Up @@ -97,14 +96,14 @@ GitHub.
```{note}
On recent macOS versions, AirPlay Receiver can occupy port 5000. If Flask
cannot bind to it, disable AirPlay Receiver or run on another port, and
use the same port in `GITHUB_REDIRECT_URI` and the OAuth App's callback URL.
use the same port in the OAuth App's callback URL.
```

## What happens during login

1. `@github.login` redirects the browser to GitHub and stores a random
`state` value in the Flask session.
2. GitHub sends the browser back to `GITHUB_REDIRECT_URI` with an
2. GitHub sends the browser back to the `@github.callback` route with an
authorization code and the `state`.
3. `@github.callback` checks and consumes the `state`. If it is valid, the
extension exchanges the code for a token, saves the token in the session,
Expand Down
1 change: 0 additions & 1 deletion examples/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ def create_app() -> flask.Flask:
SECRET_KEY=os.environ["SECRET_KEY"],
GITHUB_CLIENT_ID=os.environ["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET=os.environ["GITHUB_CLIENT_SECRET"],
GITHUB_REDIRECT_URI="http://localhost:5000/auth/callback",
)
github.init_app(app)
app.register_blueprint(auth)
Expand Down
1 change: 0 additions & 1 deletion examples/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ def create_app() -> flask.Flask:
SECRET_KEY=os.environ["SECRET_KEY"],
GITHUB_CLIENT_ID=os.environ["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET=os.environ["GITHUB_CLIENT_SECRET"],
GITHUB_REDIRECT_URI="http://localhost:5000/callback",
# Optional. Both can also be passed to GitHub(...).
GITHUB_SCOPE="read:user user:email",
GITHUB_LOGIN_VIEW="login",
Expand Down
1 change: 0 additions & 1 deletion examples/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ def create_app() -> flask.Flask:
SECRET_KEY=os.environ["SECRET_KEY"],
GITHUB_CLIENT_ID=os.environ["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET=os.environ["GITHUB_CLIENT_SECRET"],
GITHUB_REDIRECT_URI="http://localhost:5000/callback",
)
github.init_app(app)

Expand Down
1 change: 0 additions & 1 deletion examples/single_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
SECRET_KEY=os.environ["SECRET_KEY"],
GITHUB_CLIENT_ID=os.environ["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET=os.environ["GITHUB_CLIENT_SECRET"],
GITHUB_REDIRECT_URI="http://localhost:5000/callback",
)
github = flask_githubkit.GitHub(app)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "flask-githubkit"
version = "0.1.5"
version = "0.1.6"
description = "Flask extension for GitHub OAuth login, built on GitHubKit. Handles expiring-token refresh and server-side sessions."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
31 changes: 25 additions & 6 deletions src/flask_githubkit/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
CLIENT_ATTR = "flask_githubkit_client"
LOGIN_VIEW = "login"
LOGIN_MARK = "_flask_githubkit_login"
CALLBACK_MARK = "_flask_githubkit_callback"
CALLBACK_VIEW_KEY = "GITHUB_CALLBACK_VIEW"

ViewResult = (
werkzeug.wrappers.Response | tuple[werkzeug.wrappers.Response, int]
Expand Down Expand Up @@ -131,6 +133,7 @@ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> ViewResult:
flask_githubkit.store.Token.save(strategy)
return view_func(strategy.token, *args, **kwargs)

setattr(wrapper, CALLBACK_MARK, self)
return wrapper

def client(self) -> "githubkit.GitHub[typing.Any] | None":
Expand Down Expand Up @@ -184,20 +187,36 @@ def _url(self, config: flask_githubkit.config.Config) -> str:
query = urllib.parse.urlencode(
{
"client_id": config.client_id,
"redirect_uri": config.redirect_uri,
"redirect_uri": self._redirect_uri(config),
"scope": self.scope or config.scope,
"state": flask_githubkit.store.State.new(),
}
)
return f"{AUTHORIZE_URL}?{query}"

def _redirect_uri(self, config: flask_githubkit.config.Config) -> str:
"""Return ``GITHUB_REDIRECT_URI``, or the ``@callback`` view's URL."""
if config.redirect_uri:
return config.redirect_uri
app_config = flask.current_app.config
endpoint = app_config.get(CALLBACK_VIEW_KEY)
if endpoint is None:
endpoint = self._decorated_endpoint(CALLBACK_MARK)
if endpoint is None:
raise RuntimeError(
"No callback URL. Decorate the callback view with "
"@github.callback or set GITHUB_REDIRECT_URI."
)
app_config[CALLBACK_VIEW_KEY] = endpoint
return flask.url_for(endpoint, _external=True)

@staticmethod
def _check_state() -> bool:
received = flask.request.args.get("state", "")
return flask_githubkit.store.State.check(received)

@staticmethod
def _exchange(
self,
code: str,
config: flask_githubkit.config.Config,
) -> githubkit.auth.OAuthTokenAuthStrategy:
Expand All @@ -206,7 +225,7 @@ def _exchange(
client_secret=config.client_secret,
)
return app.as_web_user(
code, redirect_uri=config.redirect_uri
code, redirect_uri=self._redirect_uri(config)
).exchange_token(githubkit.GitHub(auth=app))

def _expired(
Expand All @@ -228,10 +247,10 @@ def _expired(
"blueprint named 'auth'."
) from build_error

def _decorated_endpoint(self) -> str | None:
"""Return the endpoint of the view decorated with ``@login``."""
def _decorated_endpoint(self, mark: str = LOGIN_MARK) -> str | None:
"""Return the endpoint of the view tagged with ``mark``."""
for endpoint, view in flask.current_app.view_functions.items():
if getattr(view, LOGIN_MARK, None) is self:
if getattr(view, mark, None) is self:
return endpoint
return None

Expand Down
23 changes: 11 additions & 12 deletions src/flask_githubkit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
class Config:
"""OAuth App settings read from the Flask app config.

Reads ``GITHUB_CLIENT_ID``, ``GITHUB_CLIENT_SECRET`` and
``GITHUB_REDIRECT_URI``. ``SECRET_KEY`` must also be set, because
the OAuth state and tokens are kept in ``flask.session``.
Reads ``GITHUB_CLIENT_ID`` and ``GITHUB_CLIENT_SECRET``.
``SECRET_KEY`` must also be set, because the OAuth state and
tokens are kept in ``flask.session``. ``GITHUB_REDIRECT_URI`` is
optional: without it the callback URL is built from the view
decorated with ``@callback`` and the current host.
``GITHUB_SCOPE`` is optional: the space-separated OAuth scope to
request, ``read:user`` by default.
"""

client_id: str
client_secret: str
redirect_uri: str
redirect_uri: str | None = None
scope: str = SCOPE

@staticmethod
Expand All @@ -33,15 +35,12 @@ def from_current_app() -> "Config | None":
secret_key = app_config.get("SECRET_KEY")
if not all(
isinstance(value, str) and value
for value in (
client_id,
client_secret,
redirect_uri,
scope,
secret_key,
)
for value in (client_id, client_secret, scope, secret_key)
):
return None
return Config(
str(client_id), str(client_secret), str(redirect_uri), str(scope)
str(client_id),
str(client_secret),
str(redirect_uri) if redirect_uri else None,
str(scope),
)
33 changes: 33 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ def test_login_redirects_to_github_with_state_scope_and_callback(http_client):
assert session["flask_githubkit.state"] == query["state"][0]


def test_login_derives_callback_url_from_host_when_redirect_uri_unset(
app, http_client
):
del app.config["GITHUB_REDIRECT_URI"]

response = http_client.get("/login", base_url="https://example.com")

query = urllib.parse.parse_qs(
urllib.parse.urlparse(response.headers["Location"]).query
)
assert query["redirect_uri"] == ["https://example.com/callback"]


def test_callback_endpoint_is_stored_in_config_once(app, http_client):
del app.config["GITHUB_REDIRECT_URI"]

http_client.get("/login")

assert app.config["GITHUB_CALLBACK_VIEW"] == "callback"
del app.view_functions["callback"]
app.view_functions["callback"] = lambda: ""
response = http_client.get("/login")
assert response.status_code == 302


def test_login_needs_callback_view_when_redirect_uri_unset(app, http_client):
del app.config["GITHUB_REDIRECT_URI"]
del app.view_functions["callback"]

with pytest.raises(RuntimeError, match="No callback URL"):
http_client.get("/login")


def test_login_reports_missing_configuration(app, http_client):
app.config["GITHUB_CLIENT_ID"] = None

Expand Down
Loading
Loading