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.
@@ -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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .env
@@ -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
+ [![Test](https://github.com/sillohq/oauth/actions/workflows/test.yml/badge.svg)](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.