ipyauth-pkce 1.0.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.
- ipyauth_pkce-1.0.0/.gitignore +30 -0
- ipyauth_pkce-1.0.0/LICENSE +22 -0
- ipyauth_pkce-1.0.0/PKG-INFO +239 -0
- ipyauth_pkce-1.0.0/README.md +218 -0
- ipyauth_pkce-1.0.0/install.json +5 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/__init__.py +10 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/__init__.py +2 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/ipyauth-pkce.json +7 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/server_extension.py +336 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/templates/assets/main.js +46 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/templates/assets/util.js +136 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_callback/templates/index.html +16 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/__init__.py +7 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_config.py +7 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_params_auth0.py +37 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_params_base.py +73 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_params_google.py +39 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_params_keycloak.py +45 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_util.py +95 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/_widget_box.py +174 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/img/auth0-logo.png +0 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/img/google-logo.png +0 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/ipyauth_widget/img/keycloak-logo.png +0 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/.babelrc +8 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/.eslintignore +3 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/.eslintrc.json +36 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/.prettierrc +6 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/README.md +25 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/package-lock.json +11829 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/package.json +69 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/auth-error-display.test.js +163 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/authorize-url.test.js +105 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/auto-login-attempt.test.js +92 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/automatic-renewal.test.js +61 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/broadcast-channel-isolation.test.js +90 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/callback-state-validation.test.js +144 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/callback-util.test.js +149 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/focus-next-cell.test.js +107 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/id-token-validation.test.js +188 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/oidc-providers.test.js +122 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/popup-manual-close.test.js +200 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/popup-user-activation.test.js +59 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-auth-url-pkce.test.js +122 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-jwt-roundtrip.test.js +95 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-keycloak-audience.test.js +138 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-origin-mismatch.test.js +136 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-pkce-challenge.test.js +70 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-pkce-verifier.test.js +52 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-popup-blocked-bug-condition.test.js +256 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-popup-blocked-preservation.test.js +163 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-security-bug-condition.test.js +324 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/property-security-preservation.test.js +199 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/__tests__/seconds-to-exp.test.js +27 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/app_commands.js +95 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/labplugin.js +32 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/providers/auth0.js +14 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/providers/google.js +71 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/providers/index.js +21 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/providers/keycloak.js +15 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/test-setup/setup-crypto.js +42 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/widget.js +1 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/widget_auth.js +626 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/widget_box.js +125 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/js/src/widget_util.js +344 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/package.json +73 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/218.f367b7ba1b52d7bdfccd.js +1 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/442.460d9c3da800991e4a71.js +2 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/442.460d9c3da800991e4a71.js.LICENSE.txt +10 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/remoteEntry.68f8fd1f6638911fce79.js +1 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/style.js +4 -0
- ipyauth_pkce-1.0.0/ipyauth_pkce/labextension/static/third-party-licenses.json +34 -0
- ipyauth_pkce-1.0.0/pyproject.toml +102 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build artifacts
|
|
5
|
+
ipyauth_pkce/js/dist/
|
|
6
|
+
ipyauth_pkce/labextension/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*.egg-info/
|
|
14
|
+
*.egg
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.hypothesis/
|
|
17
|
+
|
|
18
|
+
# Virtual environments
|
|
19
|
+
.venv*/
|
|
20
|
+
|
|
21
|
+
# Environment / secrets
|
|
22
|
+
.env
|
|
23
|
+
*.env.local
|
|
24
|
+
*.env
|
|
25
|
+
|
|
26
|
+
# IDE / tools
|
|
27
|
+
.kiro/
|
|
28
|
+
|
|
29
|
+
# Local scratch notebooks
|
|
30
|
+
notebooks/test-*.ipynb
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Olivier Borderies
|
|
4
|
+
Copyright (c) 2026 Daniel Fernández (https://github.com/danifr)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ipyauth-pkce
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Jupyter widget for OAuth2 authentication with PKCE — sign in from a notebook cell and use the token in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/danifr/ipyauth-pkce
|
|
6
|
+
Project-URL: Repository, https://github.com/danifr/ipyauth-pkce
|
|
7
|
+
Author: danifr
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: Auth0,Google,Keycloak,OAuth2,PKCE,ipywidget,javascript
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: ipywidgets<9,>=8.0
|
|
18
|
+
Requires-Dist: jupyter-server>=2.13.0
|
|
19
|
+
Requires-Dist: python-dotenv>=1.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# ipyauth-pkce
|
|
23
|
+
|
|
24
|
+
A [Jupyter widget](https://ipywidgets.readthedocs.io/en/stable/) that lets notebook users authenticate via OAuth2 (Authorization Code with PKCE) directly from a notebook cell.
|
|
25
|
+
|
|
26
|
+
After signing in, the access token is available in the Python kernel for making requests to protected APIs.
|
|
27
|
+
|
|
28
|
+
## Demo
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Supported Providers
|
|
33
|
+
|
|
34
|
+
- Keycloak (fully tested with PKCE)
|
|
35
|
+
- Auth0
|
|
36
|
+
- Google
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python >= 3.12
|
|
41
|
+
- JupyterLab >= 4.0
|
|
42
|
+
- ipywidgets >= 8.0, < 9
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ipyauth-pkce
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The package installs both the Python widget and the JupyterLab extension automatically.
|
|
51
|
+
|
|
52
|
+
## Usage (Keycloak)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from ipyauth_pkce import ParamsKeycloak, Auth
|
|
56
|
+
|
|
57
|
+
params = ParamsKeycloak(
|
|
58
|
+
base_url='https://your-keycloak-server/auth',
|
|
59
|
+
realm='your-realm',
|
|
60
|
+
client_id='your-client-id',
|
|
61
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
62
|
+
scope='openid profile',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
session = Auth(params=params)
|
|
66
|
+
session # displays the widget — click "Sign In"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`redirect_uri` must be your Jupyter server's URL followed by `/callback/`, and
|
|
70
|
+
it must be registered as an allowed redirect URI for the client at your
|
|
71
|
+
provider. The `scope` must include `openid` and `profile`. Values can also be
|
|
72
|
+
loaded from a dotenv file: `ParamsKeycloak(dotenv_folder='.',
|
|
73
|
+
dotenv_file='ipyauth-keycloak-myrealm.env')` (the file name must start with
|
|
74
|
+
`ipyauth-<provider>-` and end with `.env`); explicit arguments override it.
|
|
75
|
+
|
|
76
|
+
The widget tries to open the sign-in popup as soon as it is displayed; if the
|
|
77
|
+
browser blocks that, click "Sign In".
|
|
78
|
+
|
|
79
|
+
After authenticating:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
session.access_token # the JWT access token
|
|
83
|
+
session.headers # {'Authorization': 'Bearer eyJ...'}
|
|
84
|
+
session.is_authenticated # True / False
|
|
85
|
+
session.logged_as # user email or name
|
|
86
|
+
session.expires_at # expiry timestamp
|
|
87
|
+
|
|
88
|
+
# Use with requests
|
|
89
|
+
import requests
|
|
90
|
+
r = requests.get('https://api.example.com/data', headers=session.headers)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Other Providers
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from ipyauth_pkce import ParamsAuth0, Auth
|
|
97
|
+
|
|
98
|
+
params = ParamsAuth0(
|
|
99
|
+
domain='your-tenant.auth0.com',
|
|
100
|
+
client_id='your-client-id',
|
|
101
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
102
|
+
scope='openid profile',
|
|
103
|
+
)
|
|
104
|
+
session = Auth(params=params)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from ipyauth_pkce import ParamsGoogle, Auth
|
|
109
|
+
|
|
110
|
+
params = ParamsGoogle(
|
|
111
|
+
client_id='your-client-id.apps.googleusercontent.com',
|
|
112
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
113
|
+
scope='openid profile email',
|
|
114
|
+
)
|
|
115
|
+
session = Auth(params=params)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## How It Works
|
|
119
|
+
|
|
120
|
+
1. The widget opens a popup window pointing to the OAuth provider's authorization endpoint
|
|
121
|
+
2. The user authenticates in the popup
|
|
122
|
+
3. The provider redirects back to `/callback/` with an authorization code
|
|
123
|
+
4. The callback page sends the code back to the widget via `postMessage`
|
|
124
|
+
5. The widget asks the Jupyter server to exchange the code for tokens (PKCE: a public client, no client secret)
|
|
125
|
+
6. The widget validates the ID token, and the access token becomes available in the Python kernel
|
|
126
|
+
|
|
127
|
+
When the token expires the widget clears it; sign in again to get a new one (refresh tokens are not used).
|
|
128
|
+
|
|
129
|
+
## Security
|
|
130
|
+
|
|
131
|
+
- Authorization Code flow with PKCE (no implicit flow, no client secrets in the browser)
|
|
132
|
+
- Server-side token exchange via Jupyter server extension
|
|
133
|
+
- XSRF protection on the token exchange endpoint
|
|
134
|
+
- Origin validation on postMessage callbacks
|
|
135
|
+
- Content-Security-Policy headers on the callback page
|
|
136
|
+
- Input validation on all OAuth parameters (SSRF protection, RFC 7636 compliance)
|
|
137
|
+
- OIDC ID-token validation (issuer, audience, subject, expiry, issued-at, nonce, and provider JWKS signature)
|
|
138
|
+
- Google bearer tokens are sent in Authorization headers rather than API query strings
|
|
139
|
+
|
|
140
|
+
### Token endpoint configuration
|
|
141
|
+
|
|
142
|
+
The server-side token exchange decides which token endpoint to send the
|
|
143
|
+
authorization code and PKCE verifier to — it never blindly trusts a
|
|
144
|
+
browser-supplied URL, because that endpoint is the one place a leaked code
|
|
145
|
+
could be redeemed by an attacker.
|
|
146
|
+
|
|
147
|
+
- **Google** works with no configuration: its fixed endpoint
|
|
148
|
+
(`https://oauth2.googleapis.com/token`) is allowed by default.
|
|
149
|
+
- **Any other provider** (Keycloak, Auth0, other OIDC servers, or local
|
|
150
|
+
development) requires a single environment variable on the Jupyter server,
|
|
151
|
+
set before Jupyter starts:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
export IPYAUTH_TOKEN_ENDPOINT="https://your-provider/realms/your-realm/protocol/openid-connect/token"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
When `IPYAUTH_TOKEN_ENDPOINT` is set, the server uses that value and ignores
|
|
158
|
+
whatever the browser sends, so the token exchange always targets a trusted
|
|
159
|
+
endpoint. The value must be an HTTPS URL (plain `http` is accepted only for
|
|
160
|
+
`localhost` during development) with no credentials, query string, or fragment.
|
|
161
|
+
|
|
162
|
+
If a non-Google provider is used without `IPYAUTH_TOKEN_ENDPOINT` set, the
|
|
163
|
+
token exchange is rejected and the widget never receives an access token. The
|
|
164
|
+
server responds with:
|
|
165
|
+
|
|
166
|
+
> Token exchange endpoint not configured. Set the IPYAUTH_TOKEN_ENDPOINT
|
|
167
|
+
> environment variable on the Jupyter server to your provider's token endpoint.
|
|
168
|
+
|
|
169
|
+
In managed or multi-user deployments this variable is set once by the server
|
|
170
|
+
operator in the Jupyter server environment.
|
|
171
|
+
|
|
172
|
+
## Architecture
|
|
173
|
+
|
|
174
|
+
- `ipyauth_pkce/ipyauth_callback/` — Jupyter server extension that serves the OAuth callback page and handles token exchange
|
|
175
|
+
- `ipyauth_pkce/ipyauth_widget/` — Python widget (ipywidgets VBox)
|
|
176
|
+
- `ipyauth_pkce/js/` — JavaScript frontend (JupyterLab extension)
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
Requires Node.js (24 or newer) in addition to Python. The JupyterLab extension in
|
|
181
|
+
`ipyauth_pkce/labextension/` is a build output and is not committed: it is built
|
|
182
|
+
from `ipyauth_pkce/js` whenever the package is built or installed from source.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# Virtual environment (the dev/ scripts expect it at .venv; override with VENV=...)
|
|
186
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
187
|
+
|
|
188
|
+
# Editable install; also builds the JupyterLab extension
|
|
189
|
+
pip install -e . pytest "jupyterlab>=4,<5" jupyter-builder
|
|
190
|
+
cd ipyauth_pkce/js && npm ci
|
|
191
|
+
|
|
192
|
+
# JS lint and tests (from ipyauth_pkce/js)
|
|
193
|
+
npm run lint
|
|
194
|
+
npm test
|
|
195
|
+
|
|
196
|
+
# Rebuild the JupyterLab extension after changing ipyauth_pkce/js/src
|
|
197
|
+
npm run build
|
|
198
|
+
|
|
199
|
+
# Python tests (from the repo root)
|
|
200
|
+
pytest
|
|
201
|
+
|
|
202
|
+
# Build the wheel and sdist (the version comes from the latest git tag).
|
|
203
|
+
# An existing ipyauth_pkce/labextension is reused, so run `npm run build` first.
|
|
204
|
+
hatch build
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
To release, publish a GitHub release with a new tag `vX.Y.Z`: CI runs the tests,
|
|
208
|
+
builds the extension and uploads version `X.Y.Z` to PyPI.
|
|
209
|
+
|
|
210
|
+
The JavaScript widget has its own version (`ipyauth_pkce/js/package.json`, and
|
|
211
|
+
`_FRONTEND_VERSION` in `_widget_box.py`, which must match). Bump both only when
|
|
212
|
+
the contract between the Python and JavaScript sides changes, such as a synced
|
|
213
|
+
trait being added, renamed or removed.
|
|
214
|
+
|
|
215
|
+
### Containers
|
|
216
|
+
|
|
217
|
+
`dev/` contains helper scripts that use Podman (the Dockerfiles also work with
|
|
218
|
+
Docker if you run the commands by hand). They use the `.venv` environment
|
|
219
|
+
from above (or the one set in `VENV`); `run-test.sh` additionally needs
|
|
220
|
+
`pip install hatch-vcs hatch-jupyter-builder` in it.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Build the wheel and a JupyterLab image that installs it, then run it.
|
|
224
|
+
# Pass the token endpoint for non-Google providers.
|
|
225
|
+
dev/run-test.sh "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token"
|
|
226
|
+
|
|
227
|
+
# Fast loop: editable install with the source bind-mounted.
|
|
228
|
+
# --js rebuilds the labextension first; --watch rebuilds on every change.
|
|
229
|
+
dev/run-dev.sh "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" --js
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Both start JupyterLab on http://localhost:8888 (override with `PORT=...`).
|
|
233
|
+
`run-test.sh` keeps Jupyter's token login (open the URL with `?token=...` from its
|
|
234
|
+
output); `run-dev.sh` disables authentication for convenience, so only use it on
|
|
235
|
+
a local machine.
|
|
236
|
+
|
|
237
|
+
## Credits
|
|
238
|
+
|
|
239
|
+
This project is based on [ipyauth](https://gitlab.com/oscar6echo/ipyauth) by oscar6echo, with Keycloak support from the [e-ucm fork](https://github.com/e-ucm/ipyauth/tree/newKeycloak). This is a modernized version that uses the Authorization Code flow with PKCE instead of the legacy implicit flow. Tested with Python 3.12+, JupyterLab 4+, and modern versions of Keycloak that support PKCE.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# ipyauth-pkce
|
|
2
|
+
|
|
3
|
+
A [Jupyter widget](https://ipywidgets.readthedocs.io/en/stable/) that lets notebook users authenticate via OAuth2 (Authorization Code with PKCE) directly from a notebook cell.
|
|
4
|
+
|
|
5
|
+
After signing in, the access token is available in the Python kernel for making requests to protected APIs.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Supported Providers
|
|
12
|
+
|
|
13
|
+
- Keycloak (fully tested with PKCE)
|
|
14
|
+
- Auth0
|
|
15
|
+
- Google
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Python >= 3.12
|
|
20
|
+
- JupyterLab >= 4.0
|
|
21
|
+
- ipywidgets >= 8.0, < 9
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ipyauth-pkce
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package installs both the Python widget and the JupyterLab extension automatically.
|
|
30
|
+
|
|
31
|
+
## Usage (Keycloak)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from ipyauth_pkce import ParamsKeycloak, Auth
|
|
35
|
+
|
|
36
|
+
params = ParamsKeycloak(
|
|
37
|
+
base_url='https://your-keycloak-server/auth',
|
|
38
|
+
realm='your-realm',
|
|
39
|
+
client_id='your-client-id',
|
|
40
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
41
|
+
scope='openid profile',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
session = Auth(params=params)
|
|
45
|
+
session # displays the widget — click "Sign In"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`redirect_uri` must be your Jupyter server's URL followed by `/callback/`, and
|
|
49
|
+
it must be registered as an allowed redirect URI for the client at your
|
|
50
|
+
provider. The `scope` must include `openid` and `profile`. Values can also be
|
|
51
|
+
loaded from a dotenv file: `ParamsKeycloak(dotenv_folder='.',
|
|
52
|
+
dotenv_file='ipyauth-keycloak-myrealm.env')` (the file name must start with
|
|
53
|
+
`ipyauth-<provider>-` and end with `.env`); explicit arguments override it.
|
|
54
|
+
|
|
55
|
+
The widget tries to open the sign-in popup as soon as it is displayed; if the
|
|
56
|
+
browser blocks that, click "Sign In".
|
|
57
|
+
|
|
58
|
+
After authenticating:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
session.access_token # the JWT access token
|
|
62
|
+
session.headers # {'Authorization': 'Bearer eyJ...'}
|
|
63
|
+
session.is_authenticated # True / False
|
|
64
|
+
session.logged_as # user email or name
|
|
65
|
+
session.expires_at # expiry timestamp
|
|
66
|
+
|
|
67
|
+
# Use with requests
|
|
68
|
+
import requests
|
|
69
|
+
r = requests.get('https://api.example.com/data', headers=session.headers)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Other Providers
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from ipyauth_pkce import ParamsAuth0, Auth
|
|
76
|
+
|
|
77
|
+
params = ParamsAuth0(
|
|
78
|
+
domain='your-tenant.auth0.com',
|
|
79
|
+
client_id='your-client-id',
|
|
80
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
81
|
+
scope='openid profile',
|
|
82
|
+
)
|
|
83
|
+
session = Auth(params=params)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from ipyauth_pkce import ParamsGoogle, Auth
|
|
88
|
+
|
|
89
|
+
params = ParamsGoogle(
|
|
90
|
+
client_id='your-client-id.apps.googleusercontent.com',
|
|
91
|
+
redirect_uri='http://localhost:8888/callback/',
|
|
92
|
+
scope='openid profile email',
|
|
93
|
+
)
|
|
94
|
+
session = Auth(params=params)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How It Works
|
|
98
|
+
|
|
99
|
+
1. The widget opens a popup window pointing to the OAuth provider's authorization endpoint
|
|
100
|
+
2. The user authenticates in the popup
|
|
101
|
+
3. The provider redirects back to `/callback/` with an authorization code
|
|
102
|
+
4. The callback page sends the code back to the widget via `postMessage`
|
|
103
|
+
5. The widget asks the Jupyter server to exchange the code for tokens (PKCE: a public client, no client secret)
|
|
104
|
+
6. The widget validates the ID token, and the access token becomes available in the Python kernel
|
|
105
|
+
|
|
106
|
+
When the token expires the widget clears it; sign in again to get a new one (refresh tokens are not used).
|
|
107
|
+
|
|
108
|
+
## Security
|
|
109
|
+
|
|
110
|
+
- Authorization Code flow with PKCE (no implicit flow, no client secrets in the browser)
|
|
111
|
+
- Server-side token exchange via Jupyter server extension
|
|
112
|
+
- XSRF protection on the token exchange endpoint
|
|
113
|
+
- Origin validation on postMessage callbacks
|
|
114
|
+
- Content-Security-Policy headers on the callback page
|
|
115
|
+
- Input validation on all OAuth parameters (SSRF protection, RFC 7636 compliance)
|
|
116
|
+
- OIDC ID-token validation (issuer, audience, subject, expiry, issued-at, nonce, and provider JWKS signature)
|
|
117
|
+
- Google bearer tokens are sent in Authorization headers rather than API query strings
|
|
118
|
+
|
|
119
|
+
### Token endpoint configuration
|
|
120
|
+
|
|
121
|
+
The server-side token exchange decides which token endpoint to send the
|
|
122
|
+
authorization code and PKCE verifier to — it never blindly trusts a
|
|
123
|
+
browser-supplied URL, because that endpoint is the one place a leaked code
|
|
124
|
+
could be redeemed by an attacker.
|
|
125
|
+
|
|
126
|
+
- **Google** works with no configuration: its fixed endpoint
|
|
127
|
+
(`https://oauth2.googleapis.com/token`) is allowed by default.
|
|
128
|
+
- **Any other provider** (Keycloak, Auth0, other OIDC servers, or local
|
|
129
|
+
development) requires a single environment variable on the Jupyter server,
|
|
130
|
+
set before Jupyter starts:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
export IPYAUTH_TOKEN_ENDPOINT="https://your-provider/realms/your-realm/protocol/openid-connect/token"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
When `IPYAUTH_TOKEN_ENDPOINT` is set, the server uses that value and ignores
|
|
137
|
+
whatever the browser sends, so the token exchange always targets a trusted
|
|
138
|
+
endpoint. The value must be an HTTPS URL (plain `http` is accepted only for
|
|
139
|
+
`localhost` during development) with no credentials, query string, or fragment.
|
|
140
|
+
|
|
141
|
+
If a non-Google provider is used without `IPYAUTH_TOKEN_ENDPOINT` set, the
|
|
142
|
+
token exchange is rejected and the widget never receives an access token. The
|
|
143
|
+
server responds with:
|
|
144
|
+
|
|
145
|
+
> Token exchange endpoint not configured. Set the IPYAUTH_TOKEN_ENDPOINT
|
|
146
|
+
> environment variable on the Jupyter server to your provider's token endpoint.
|
|
147
|
+
|
|
148
|
+
In managed or multi-user deployments this variable is set once by the server
|
|
149
|
+
operator in the Jupyter server environment.
|
|
150
|
+
|
|
151
|
+
## Architecture
|
|
152
|
+
|
|
153
|
+
- `ipyauth_pkce/ipyauth_callback/` — Jupyter server extension that serves the OAuth callback page and handles token exchange
|
|
154
|
+
- `ipyauth_pkce/ipyauth_widget/` — Python widget (ipywidgets VBox)
|
|
155
|
+
- `ipyauth_pkce/js/` — JavaScript frontend (JupyterLab extension)
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
Requires Node.js (24 or newer) in addition to Python. The JupyterLab extension in
|
|
160
|
+
`ipyauth_pkce/labextension/` is a build output and is not committed: it is built
|
|
161
|
+
from `ipyauth_pkce/js` whenever the package is built or installed from source.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Virtual environment (the dev/ scripts expect it at .venv; override with VENV=...)
|
|
165
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
166
|
+
|
|
167
|
+
# Editable install; also builds the JupyterLab extension
|
|
168
|
+
pip install -e . pytest "jupyterlab>=4,<5" jupyter-builder
|
|
169
|
+
cd ipyauth_pkce/js && npm ci
|
|
170
|
+
|
|
171
|
+
# JS lint and tests (from ipyauth_pkce/js)
|
|
172
|
+
npm run lint
|
|
173
|
+
npm test
|
|
174
|
+
|
|
175
|
+
# Rebuild the JupyterLab extension after changing ipyauth_pkce/js/src
|
|
176
|
+
npm run build
|
|
177
|
+
|
|
178
|
+
# Python tests (from the repo root)
|
|
179
|
+
pytest
|
|
180
|
+
|
|
181
|
+
# Build the wheel and sdist (the version comes from the latest git tag).
|
|
182
|
+
# An existing ipyauth_pkce/labextension is reused, so run `npm run build` first.
|
|
183
|
+
hatch build
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
To release, publish a GitHub release with a new tag `vX.Y.Z`: CI runs the tests,
|
|
187
|
+
builds the extension and uploads version `X.Y.Z` to PyPI.
|
|
188
|
+
|
|
189
|
+
The JavaScript widget has its own version (`ipyauth_pkce/js/package.json`, and
|
|
190
|
+
`_FRONTEND_VERSION` in `_widget_box.py`, which must match). Bump both only when
|
|
191
|
+
the contract between the Python and JavaScript sides changes, such as a synced
|
|
192
|
+
trait being added, renamed or removed.
|
|
193
|
+
|
|
194
|
+
### Containers
|
|
195
|
+
|
|
196
|
+
`dev/` contains helper scripts that use Podman (the Dockerfiles also work with
|
|
197
|
+
Docker if you run the commands by hand). They use the `.venv` environment
|
|
198
|
+
from above (or the one set in `VENV`); `run-test.sh` additionally needs
|
|
199
|
+
`pip install hatch-vcs hatch-jupyter-builder` in it.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# Build the wheel and a JupyterLab image that installs it, then run it.
|
|
203
|
+
# Pass the token endpoint for non-Google providers.
|
|
204
|
+
dev/run-test.sh "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token"
|
|
205
|
+
|
|
206
|
+
# Fast loop: editable install with the source bind-mounted.
|
|
207
|
+
# --js rebuilds the labextension first; --watch rebuilds on every change.
|
|
208
|
+
dev/run-dev.sh "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" --js
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Both start JupyterLab on http://localhost:8888 (override with `PORT=...`).
|
|
212
|
+
`run-test.sh` keeps Jupyter's token login (open the URL with `?token=...` from its
|
|
213
|
+
output); `run-dev.sh` disables authentication for convenience, so only use it on
|
|
214
|
+
a local machine.
|
|
215
|
+
|
|
216
|
+
## Credits
|
|
217
|
+
|
|
218
|
+
This project is based on [ipyauth](https://gitlab.com/oscar6echo/ipyauth) by oscar6echo, with Keycloak support from the [e-ucm fork](https://github.com/e-ucm/ipyauth/tree/newKeycloak). This is a modernized version that uses the Authorization Code flow with PKCE instead of the legacy implicit flow. Tested with Python 3.12+, JupyterLab 4+, and modern versions of Keycloak that support PKCE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("ipyauth-pkce")
|
|
5
|
+
except PackageNotFoundError: # running from a source checkout without installing
|
|
6
|
+
__version__ = "0.0.0+unknown"
|
|
7
|
+
|
|
8
|
+
from .ipyauth_widget import Auth, ParamsAuth0, ParamsGoogle, ParamsKeycloak
|
|
9
|
+
|
|
10
|
+
__all__ = ['Auth', 'ParamsAuth0', 'ParamsGoogle', 'ParamsKeycloak']
|