sillo-oauth 0.1.0__tar.gz
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.
- sillo_oauth-0.1.0/.github/workflows/release.yml +103 -0
- sillo_oauth-0.1.0/.github/workflows/test.yml +88 -0
- sillo_oauth-0.1.0/.gitignore +13 -0
- sillo_oauth-0.1.0/CHANGELOG.md +76 -0
- sillo_oauth-0.1.0/LICENSE +27 -0
- sillo_oauth-0.1.0/PKG-INFO +266 -0
- sillo_oauth-0.1.0/README.md +235 -0
- sillo_oauth-0.1.0/pyproject.toml +80 -0
- sillo_oauth-0.1.0/sillo_oauth/__init__.py +104 -0
- sillo_oauth-0.1.0/sillo_oauth/errors.py +130 -0
- sillo_oauth-0.1.0/sillo_oauth/flow.py +724 -0
- sillo_oauth-0.1.0/sillo_oauth/models.py +203 -0
- sillo_oauth-0.1.0/sillo_oauth/providers.py +667 -0
- sillo_oauth-0.1.0/sillo_oauth/py.typed +0 -0
- sillo_oauth-0.1.0/sillo_oauth/state.py +233 -0
- sillo_oauth-0.1.0/tests/conftest.py +226 -0
- sillo_oauth-0.1.0/tests/test_authorize_url.py +470 -0
- sillo_oauth-0.1.0/tests/test_complete.py +295 -0
- sillo_oauth-0.1.0/tests/test_profiles.py +714 -0
- sillo_oauth-0.1.0/tests/test_sillo_integration.py +708 -0
- sillo_oauth-0.1.0/tests/test_state.py +258 -0
- sillo_oauth-0.1.0/tests/test_token_exchange.py +501 -0
- sillo_oauth-0.1.0/uv.lock +565 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: Release on Tag
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'oauth-v*' # e.g. oauth-v0.1.0
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
release:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write # OIDC, for trusted publishing
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- name: Extract version from tag
|
|
23
|
+
id: version
|
|
24
|
+
run: |
|
|
25
|
+
TAG="${{ github.ref_name }}"
|
|
26
|
+
VERSION="${TAG#oauth-v}"
|
|
27
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
28
|
+
echo "📦 Releasing sillo-oauth v$VERSION"
|
|
29
|
+
|
|
30
|
+
- name: Set up Python
|
|
31
|
+
uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: '3.12'
|
|
34
|
+
|
|
35
|
+
# The install script has moved between ~/.cargo/bin and ~/.local/bin
|
|
36
|
+
# across uv versions. Add both so the PATH does not depend on which
|
|
37
|
+
# installer layout ships today.
|
|
38
|
+
- name: Install uv
|
|
39
|
+
run: |
|
|
40
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
41
|
+
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
42
|
+
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
43
|
+
|
|
44
|
+
# PyPI takes the version from the package, not the tag. Without this a
|
|
45
|
+
# tag of oauth-v0.2.0 against a pyproject still saying 0.1.0 publishes
|
|
46
|
+
# 0.1.0 and reports success, and the version it claims to have released
|
|
47
|
+
# is one that does not exist.
|
|
48
|
+
- name: The tag and the package must agree
|
|
49
|
+
run: |
|
|
50
|
+
TAG_VERSION="${{ steps.version.outputs.version }}"
|
|
51
|
+
PKG_VERSION=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
|
|
52
|
+
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
|
53
|
+
echo "::error::Tag says $TAG_VERSION, pyproject.toml says $PKG_VERSION."
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
echo "both say $PKG_VERSION"
|
|
57
|
+
|
|
58
|
+
- name: Clean old builds
|
|
59
|
+
run: rm -rf dist build ./*.egg-info
|
|
60
|
+
|
|
61
|
+
- name: Setup virtual environment
|
|
62
|
+
run: uv venv
|
|
63
|
+
|
|
64
|
+
- name: Install dependencies
|
|
65
|
+
run: uv pip install -e ".[dev]"
|
|
66
|
+
|
|
67
|
+
# A release that skips the suite is a release nobody checked. The tests
|
|
68
|
+
# need no network and no credentials, so there is no excuse for not
|
|
69
|
+
# running them here.
|
|
70
|
+
- name: Run tests
|
|
71
|
+
run: uv run pytest tests/ -q --tb=short
|
|
72
|
+
|
|
73
|
+
- name: Build the package
|
|
74
|
+
run: uv build
|
|
75
|
+
|
|
76
|
+
# py.typed has no extension, so it falls outside hatchling's default
|
|
77
|
+
# include rules and has already been missing from a wheel once. A green
|
|
78
|
+
# test run would not notice.
|
|
79
|
+
- name: The wheel must carry py.typed
|
|
80
|
+
run: |
|
|
81
|
+
python - <<'PY'
|
|
82
|
+
import glob, sys, zipfile
|
|
83
|
+
wheel = glob.glob("dist/*.whl")[0]
|
|
84
|
+
if "sillo_oauth/py.typed" not in zipfile.ZipFile(wheel).namelist():
|
|
85
|
+
sys.exit(f"::error::{wheel} ships no py.typed")
|
|
86
|
+
print(f"{wheel} carries py.typed")
|
|
87
|
+
PY
|
|
88
|
+
|
|
89
|
+
- name: Check metadata
|
|
90
|
+
run: uvx twine check dist/*
|
|
91
|
+
|
|
92
|
+
# Prefers OIDC trusted publishing (the id-token permission above); falls
|
|
93
|
+
# back to an API token when the PYPI_TOKEN secret is set. Passing an empty
|
|
94
|
+
# --token is a hard failure, so the secret must not be forwarded blindly.
|
|
95
|
+
- name: Publish to PyPI
|
|
96
|
+
env:
|
|
97
|
+
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
98
|
+
run: |
|
|
99
|
+
if [ -n "$PYPI_TOKEN" ]; then
|
|
100
|
+
uv publish --token "$PYPI_TOKEN"
|
|
101
|
+
else
|
|
102
|
+
uv publish
|
|
103
|
+
fi
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Python ${{ matrix.python-version }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ['3.10', '3.11', '3.12', '3.13']
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
|
|
27
|
+
# The install script has moved between ~/.cargo/bin and ~/.local/bin
|
|
28
|
+
# across uv versions. Add both so the PATH does not depend on which
|
|
29
|
+
# installer layout ships today.
|
|
30
|
+
- name: Install uv
|
|
31
|
+
run: |
|
|
32
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
33
|
+
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
34
|
+
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
35
|
+
|
|
36
|
+
- name: Install
|
|
37
|
+
run: |
|
|
38
|
+
uv venv
|
|
39
|
+
uv pip install -e ".[dev]" ruff mypy
|
|
40
|
+
|
|
41
|
+
# No `|| true` on any of these. The suite needs no network and no
|
|
42
|
+
# credentials, so a failure here is a real failure and gating on it is
|
|
43
|
+
# the whole point of running it.
|
|
44
|
+
- name: Lint
|
|
45
|
+
run: uv run ruff check .
|
|
46
|
+
|
|
47
|
+
- name: Format
|
|
48
|
+
run: uv run ruff format --check .
|
|
49
|
+
|
|
50
|
+
- name: Type check
|
|
51
|
+
run: uv run mypy sillo_oauth/
|
|
52
|
+
|
|
53
|
+
- name: Test
|
|
54
|
+
run: uv run pytest tests/ -q
|
|
55
|
+
|
|
56
|
+
packaging:
|
|
57
|
+
name: Wheel contents
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- uses: actions/setup-python@v5
|
|
64
|
+
with:
|
|
65
|
+
python-version: '3.12'
|
|
66
|
+
|
|
67
|
+
- name: Install uv
|
|
68
|
+
run: |
|
|
69
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
70
|
+
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
71
|
+
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
72
|
+
|
|
73
|
+
- name: Build
|
|
74
|
+
run: uv build --wheel
|
|
75
|
+
|
|
76
|
+
# py.typed has no extension, so it falls outside hatchling's default
|
|
77
|
+
# include rules. It has already been dropped from a wheel once; this
|
|
78
|
+
# keeps the Typing :: Typed classifier honest.
|
|
79
|
+
- name: The wheel must carry py.typed
|
|
80
|
+
run: |
|
|
81
|
+
python - <<'PY'
|
|
82
|
+
import glob, sys, zipfile
|
|
83
|
+
wheel = glob.glob("dist/*.whl")[0]
|
|
84
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
85
|
+
if "sillo_oauth/py.typed" not in names:
|
|
86
|
+
sys.exit(f"::error::{wheel} ships no py.typed")
|
|
87
|
+
print(f"{wheel} carries py.typed")
|
|
88
|
+
PY
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-08-08
|
|
8
|
+
|
|
9
|
+
First release. The package was written and then reviewed in one sitting, so
|
|
10
|
+
the fixes below are against unreleased commits rather than against anything
|
|
11
|
+
anyone installed — they are kept because two of them are worth a reader's
|
|
12
|
+
attention, and because the reasoning is the useful part.
|
|
13
|
+
|
|
14
|
+
Requires `sillo-framework>=0.0.1a15`. That is still an alpha, so this release
|
|
15
|
+
is only as stable as the framework under it; the API here is what is being
|
|
16
|
+
called stable at 0.1.0, not the ground it stands on.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `refresh_tokens()`, and `OAuthProvider.refresh_request_data()` as the
|
|
21
|
+
provider-level hook. `OAuthTokens.refresh_token` had been captured since the
|
|
22
|
+
first commit with no way to spend it. When a provider does not rotate the
|
|
23
|
+
refresh token — most reuse it and omit it from the response — the one passed
|
|
24
|
+
in is carried onto the result, so a caller that stores
|
|
25
|
+
`tokens.refresh_token` cannot overwrite a working token with `None`.
|
|
26
|
+
- `exchange(..., state_value=...)`, for applications that keep the state in the
|
|
27
|
+
session or a cache rather than the cookie.
|
|
28
|
+
- `token_headers` and `userinfo_headers` constructor arguments, merged over the
|
|
29
|
+
class defaults so adding one header does not mean restating the `Accept` that
|
|
30
|
+
several providers need in order to answer with JSON.
|
|
31
|
+
- `GithubOAuthProvider(emails_endpoint=...)`.
|
|
32
|
+
- `py.typed`, so the annotations are visible to downstream type checkers.
|
|
33
|
+
- CI across Python 3.10–3.13, gating on tests, `ruff check`, `ruff format` and
|
|
34
|
+
`mypy`, plus a packaging job that asserts the built wheel contains
|
|
35
|
+
`py.typed`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Authorize parameters that the package manages are now refused instead of
|
|
40
|
+
overridden.** `extra_params` and `authorize_params` were merged *over* the
|
|
41
|
+
computed parameters, so `extra_params={"state": ...}` replaced the value the
|
|
42
|
+
cookie had just been signed against. At best every login failed as a
|
|
43
|
+
mismatch; at worst an attacker-chosen value was sent and the CSRF guarantee
|
|
44
|
+
quietly stopped holding. The same applied to `code_challenge` and PKCE.
|
|
45
|
+
- **`OAuthTokens` no longer prints its credentials.** Every field on it is a
|
|
46
|
+
live token and the generated dataclass repr showed all of them, so
|
|
47
|
+
`logger.info("signed in %s", profile)` wrote a working access token to the
|
|
48
|
+
log, as did any traceback holding a profile in a frame.
|
|
49
|
+
- **GitHub's emails endpoint follows `userinfo_endpoint`.** It was hardcoded to
|
|
50
|
+
`api.github.com`, so pointing the provider at a GitHub Enterprise host moved
|
|
51
|
+
only half the flow and sent an Enterprise access token to the public API.
|
|
52
|
+
- **An endpoint's own query no longer duplicates a managed parameter.** A
|
|
53
|
+
configured `authorize_endpoint` carrying `state=` got ours appended after it,
|
|
54
|
+
producing a URL with the parameter twice and leaving the provider to choose.
|
|
55
|
+
- **`expires_in` is read in whatever shape the provider sent.** The old guard
|
|
56
|
+
silently dropped every lifetime that was not a plain non-negative integer
|
|
57
|
+
literal, including the decimals and numeric strings that providers do send.
|
|
58
|
+
Negatives and booleans are still dropped deliberately.
|
|
59
|
+
- Class-level header mappings are `MappingProxyType`. As plain dicts they were
|
|
60
|
+
shared by every instance, so mutating one to add a header to a single
|
|
61
|
+
provider changed every other provider of that type.
|
|
62
|
+
- Two documented examples that would have failed if anyone ran them: the module
|
|
63
|
+
docstring set the cookie before the redirect, which sillo's `Responder`
|
|
64
|
+
cannot do, and the session example called `Session.pop()`, which does not
|
|
65
|
+
exist.
|
|
66
|
+
|
|
67
|
+
### Security notes
|
|
68
|
+
|
|
69
|
+
Two of the fixes above would have been advisories had they shipped, and are
|
|
70
|
+
called out here for anyone auditing the history:
|
|
71
|
+
|
|
72
|
+
- `extra_params` could override `state` and `code_challenge`, disarming CSRF
|
|
73
|
+
and PKCE.
|
|
74
|
+
- `OAuthTokens`' repr printed live credentials into logs and tracebacks.
|
|
75
|
+
|
|
76
|
+
[0.1.0]: https://github.com/sillohq/oauth/releases/tag/oauth-v0.1.0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, sillo-Labs OSS.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
7
|
+
are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from
|
|
16
|
+
this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
19
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
20
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
22
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
23
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
24
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
25
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
26
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sillo-oauth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OAuth 2.0 / OpenID Connect login for Sillo — plain functions, no router, no middleware.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sillohq/oauth
|
|
6
|
+
Project-URL: Repository, https://github.com/sillohq/oauth
|
|
7
|
+
Project-URL: Documentation, https://sillolabs.com
|
|
8
|
+
Project-URL: Issues, https://github.com/sillohq/oauth/issues
|
|
9
|
+
Author-email: Chidebele Dunamis <techwithdunamix@gmail.com>
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asgi,github,google,oauth,oauth2,oidc,sillo,sso
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx<0.29.0,>=0.23.3
|
|
25
|
+
Requires-Dist: sillo-framework>=0.0.1a15
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.25.3; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.3.5; extra == 'dev'
|
|
29
|
+
Requires-Dist: sillo-framework[jwt,record]>=0.0.1a15; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# sillo-oauth
|
|
33
|
+
|
|
34
|
+
[](https://github.com/sillohq/oauth/actions/workflows/test.yml)
|
|
35
|
+
|
|
36
|
+
OAuth 2.0 and OpenID Connect login for [Sillo](https://github.com/sillohq/core).
|
|
37
|
+
|
|
38
|
+
Two functions and a provider object. Neither function takes a response, builds
|
|
39
|
+
one, or registers a route — so the routes, the error handling, and the decision
|
|
40
|
+
of what a login *means* stay in your application.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install sillo-oauth
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## The whole API
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sillo_oauth import GoogleOAuthProvider, authorize_url, exchange, OAuthError
|
|
50
|
+
|
|
51
|
+
google = GoogleOAuthProvider(
|
|
52
|
+
client_id=...,
|
|
53
|
+
client_secret=...,
|
|
54
|
+
state_secret=..., # signs the state cookie; your own key
|
|
55
|
+
redirect_uri="https://example.com/auth/google/callback",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/auth/google/redirect")
|
|
60
|
+
async def start(request, response):
|
|
61
|
+
authorize = authorize_url(google)
|
|
62
|
+
return response.redirect(authorize.url).set_cookie(**authorize.cookie_kwargs())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.get("/auth/google/callback")
|
|
66
|
+
async def finish(request, response):
|
|
67
|
+
try:
|
|
68
|
+
profile = await exchange(google, request)
|
|
69
|
+
except OAuthError as exc:
|
|
70
|
+
return response.redirect(f"/login?error={exc.code}")
|
|
71
|
+
|
|
72
|
+
user = await User.objects.get_or_create_from_oauth("google", profile)
|
|
73
|
+
login(request, user)
|
|
74
|
+
return response.redirect(profile.return_to or "/")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That is the entire integration. `authorize_url` is pure — no request, no I/O —
|
|
78
|
+
and returns a URL plus the state you need to store. `exchange` reads the
|
|
79
|
+
callback request and returns a verified `OAuthProfile`. Nothing else is
|
|
80
|
+
implied.
|
|
81
|
+
|
|
82
|
+
> **Local development over `http://`** needs `cookie_kwargs(secure=False)`.
|
|
83
|
+
> Otherwise the browser accepts the `Secure` cookie and never sends it back,
|
|
84
|
+
> and every callback fails as a state mismatch.
|
|
85
|
+
|
|
86
|
+
## What it deliberately does not do
|
|
87
|
+
|
|
88
|
+
Turning a verified external identity into a logged-in user is your
|
|
89
|
+
application's decision, not this package's. So there is no `on_success` hook,
|
|
90
|
+
no user model, no session handling — just the four lines after `exchange`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# a server-rendered app
|
|
94
|
+
login(request, user)
|
|
95
|
+
return response.redirect("/dashboard")
|
|
96
|
+
|
|
97
|
+
# an SPA or mobile client
|
|
98
|
+
token = create_jwt({"id": user.id}, SECRET)
|
|
99
|
+
return response.json({"access_token": token})
|
|
100
|
+
|
|
101
|
+
# linking a provider to the user who is already signed in
|
|
102
|
+
await OAuthIdentity.objects.link(request.user, "github", profile.subject)
|
|
103
|
+
|
|
104
|
+
# nothing at all — just prove the address
|
|
105
|
+
return response.json({"verified_email": profile.email})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The same two functions serve all of them.
|
|
109
|
+
|
|
110
|
+
## Providers
|
|
111
|
+
|
|
112
|
+
`GoogleOAuthProvider`, `GithubOAuthProvider`, `DiscordOAuthProvider` and
|
|
113
|
+
`MicrosoftOAuthProvider` ship with endpoints, scopes and profile mapping
|
|
114
|
+
filled in. Every one of those is overridable per instance.
|
|
115
|
+
|
|
116
|
+
Anything else uses `OAuthProvider` directly:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
gitlab = OAuthProvider(
|
|
120
|
+
name="gitlab",
|
|
121
|
+
client_id=...,
|
|
122
|
+
client_secret=...,
|
|
123
|
+
state_secret=...,
|
|
124
|
+
authorize_endpoint="https://gitlab.com/oauth/authorize",
|
|
125
|
+
token_endpoint="https://gitlab.com/oauth/token",
|
|
126
|
+
userinfo_endpoint="https://gitlab.com/api/v4/user",
|
|
127
|
+
scopes=["read_user"],
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Self-hosted installations override the endpoints, and everything derived from
|
|
132
|
+
them follows — pointing GitHub at an Enterprise host also moves the address
|
|
133
|
+
lookup it falls back to:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
github = GithubOAuthProvider(
|
|
137
|
+
...,
|
|
138
|
+
userinfo_endpoint="https://github.acme-corp.test/api/v3/user",
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Extra headers are merged over the provider's defaults, so adding one does not
|
|
143
|
+
mean restating the rest:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
acme = OAuthProvider(..., userinfo_headers={"X-Tenant": "acme"})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
To map a provider's fields yourself, pass `profile_mapper` or subclass and
|
|
150
|
+
override `map_profile`:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
acme = OAuthProvider(
|
|
154
|
+
...,
|
|
155
|
+
profile_mapper=lambda raw: {
|
|
156
|
+
"subject": raw["employee_number"],
|
|
157
|
+
"email": raw["work_email"],
|
|
158
|
+
"name": raw["full_name"],
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## `OAuthProfile`
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
profile.provider # "google"
|
|
167
|
+
profile.subject # stable provider-side id — the only safe account key
|
|
168
|
+
profile.key # "google:112233" — unique across providers
|
|
169
|
+
profile.email
|
|
170
|
+
profile.email_verified # False also means "the provider did not say"
|
|
171
|
+
profile.name
|
|
172
|
+
profile.username
|
|
173
|
+
profile.avatar_url
|
|
174
|
+
profile.raw # the untouched userinfo payload
|
|
175
|
+
profile.tokens # access/refresh tokens, for calling the provider later
|
|
176
|
+
profile.return_to # whatever you passed to authorize_url(return_to=...)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Key accounts on `subject`, never on `email`: addresses get reassigned, and an
|
|
180
|
+
unverified one is an account-takeover vector.
|
|
181
|
+
|
|
182
|
+
## Errors
|
|
183
|
+
|
|
184
|
+
Every failure raises an `OAuthError` subclass carrying a stable, URL-safe
|
|
185
|
+
`.code`, so one `except` is enough and codes can go straight into a redirect.
|
|
186
|
+
|
|
187
|
+
| `.code` | Raised when |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `denied` | The person declined consent. Not a fault — send them back to the login page. |
|
|
190
|
+
| `provider_error` | The provider reported some other `error` parameter. |
|
|
191
|
+
| `state_mismatch` | The callback does not match a redirect this server issued: no cookie, no `state`, a forged or tampered cookie, or one minted for another provider. |
|
|
192
|
+
| `state_expired` | Genuine state, but too old. Worth a "that took too long, try again". |
|
|
193
|
+
| `exchange_failed` | The provider would not trade the code for a token. |
|
|
194
|
+
| `profile_failed` | A token was issued but no usable profile came back. |
|
|
195
|
+
| `provider_misconfigured` | Programming error — a missing secret, redirect URI, or endpoint. |
|
|
196
|
+
|
|
197
|
+
## Security
|
|
198
|
+
|
|
199
|
+
* **State is signed, not stored.** No session store, no database, no sticky
|
|
200
|
+
routing — the CSRF token rides in an HMAC-signed cookie carrying an expiry
|
|
201
|
+
and the provider name, so a cookie minted for one provider cannot complete
|
|
202
|
+
another's callback.
|
|
203
|
+
* **PKCE verifiers are derived, never stored.** The verifier is recomputed at
|
|
204
|
+
exchange time as `HMAC(state_secret, state)`. Putting it in the cookie would
|
|
205
|
+
have placed a secret somewhere readable; keeping it server-side would have
|
|
206
|
+
reintroduced the state store. The provider only ever sees the S256
|
|
207
|
+
challenge.
|
|
208
|
+
* **State is verified before anything is sent to the provider**, so a forged
|
|
209
|
+
callback cannot make your server issue a token request.
|
|
210
|
+
* Reserved parameters (`state`, `code_challenge`, `client_id`, …) cannot be
|
|
211
|
+
overridden through `extra_params`. Supplying one raises rather than being
|
|
212
|
+
ignored, because an application that believes it is setting `state` and
|
|
213
|
+
silently is not has a security expectation the code no longer meets.
|
|
214
|
+
* **Tokens are redacted from reprs.** `logger.info("signed in %s", profile)` is
|
|
215
|
+
an ordinary line to write, and an error tracker collects tracebacks holding
|
|
216
|
+
profiles in frames. Neither leaks a credential; attribute access is
|
|
217
|
+
unaffected.
|
|
218
|
+
|
|
219
|
+
`state_secret` is unrelated to `client_secret`: it protects your own cookies,
|
|
220
|
+
not your relationship with the provider. Any high-entropy application key
|
|
221
|
+
works, and one can be shared across providers.
|
|
222
|
+
|
|
223
|
+
## Lower-level entry points
|
|
224
|
+
|
|
225
|
+
`exchange(provider, request)` is a convenience over functions that take no
|
|
226
|
+
request at all, for callers whose callback did not arrive as a Sillo request —
|
|
227
|
+
a worker, a CLI, a test:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
profile = await complete(provider, code=..., state=..., cookie_value=...)
|
|
231
|
+
tokens = await exchange_code(provider, code=..., verifier=...)
|
|
232
|
+
profile = await fetch_profile(provider, tokens)
|
|
233
|
+
tokens = await refresh_tokens(provider, refresh_token=...)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Storing state somewhere other than a cookie works the same way — hand the
|
|
237
|
+
stored value back explicitly:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
# at the redirect step
|
|
241
|
+
request.session["oauth_state"] = authorize.cookie_value
|
|
242
|
+
|
|
243
|
+
# at the callback — sillo's Session has get/delete, not pop
|
|
244
|
+
stored = request.session.get("oauth_state")
|
|
245
|
+
request.session.delete("oauth_state")
|
|
246
|
+
profile = await exchange(google, request, state_value=stored)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
pip install -e ".[dev]"
|
|
253
|
+
pytest # 240+ tests, no network
|
|
254
|
+
ruff check .
|
|
255
|
+
ruff format --check .
|
|
256
|
+
mypy sillo_oauth/
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The suite never touches the network and never needs real credentials. Provider
|
|
260
|
+
responses are canned through an injected `httpx` transport, and an autouse
|
|
261
|
+
fixture breaks the real transport so a test that forgets a stub fails loudly
|
|
262
|
+
instead of reaching out to Google.
|
|
263
|
+
|
|
264
|
+
## Licence
|
|
265
|
+
|
|
266
|
+
BSD-3-Clause.
|