ipyauth-pkce 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipyauth_pkce/__init__.py +10 -0
- ipyauth_pkce/ipyauth_callback/__init__.py +2 -0
- ipyauth_pkce/ipyauth_callback/server_extension.py +336 -0
- ipyauth_pkce/ipyauth_callback/templates/assets/main.js +46 -0
- ipyauth_pkce/ipyauth_callback/templates/assets/util.js +136 -0
- ipyauth_pkce/ipyauth_callback/templates/index.html +16 -0
- ipyauth_pkce/ipyauth_widget/__init__.py +7 -0
- ipyauth_pkce/ipyauth_widget/_config.py +7 -0
- ipyauth_pkce/ipyauth_widget/_params_auth0.py +37 -0
- ipyauth_pkce/ipyauth_widget/_params_base.py +73 -0
- ipyauth_pkce/ipyauth_widget/_params_google.py +39 -0
- ipyauth_pkce/ipyauth_widget/_params_keycloak.py +45 -0
- ipyauth_pkce/ipyauth_widget/_util.py +95 -0
- ipyauth_pkce/ipyauth_widget/_widget_box.py +174 -0
- ipyauth_pkce/ipyauth_widget/img/auth0-logo.png +0 -0
- ipyauth_pkce/ipyauth_widget/img/google-logo.png +0 -0
- ipyauth_pkce/ipyauth_widget/img/keycloak-logo.png +0 -0
- ipyauth_pkce-1.0.0.data/data/etc/jupyter/jupyter_server_config.d/ipyauth-pkce.json +7 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/install.json +5 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/package.json +73 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/218.f367b7ba1b52d7bdfccd.js +1 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/442.460d9c3da800991e4a71.js +2 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/442.460d9c3da800991e4a71.js.LICENSE.txt +10 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/remoteEntry.68f8fd1f6638911fce79.js +1 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/style.js +4 -0
- ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/third-party-licenses.json +34 -0
- ipyauth_pkce-1.0.0.dist-info/METADATA +239 -0
- ipyauth_pkce-1.0.0.dist-info/RECORD +30 -0
- ipyauth_pkce-1.0.0.dist-info/WHEEL +4 -0
- ipyauth_pkce-1.0.0.dist-info/licenses/LICENSE +22 -0
ipyauth_pkce/__init__.py
ADDED
|
@@ -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']
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
import tornado.httpclient
|
|
9
|
+
import tornado.web
|
|
10
|
+
from jupyter_server.base.handlers import JupyterHandler, allow_unauthenticated
|
|
11
|
+
from jupyter_server.utils import url_path_join
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# RFC 7636: code_verifier is 43-128 chars of [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
|
15
|
+
_CODE_VERIFIER_RE = re.compile(r'^[A-Za-z0-9\-._~]{43,128}$')
|
|
16
|
+
|
|
17
|
+
# Maximum length for string fields to prevent abuse
|
|
18
|
+
_MAX_FIELD_LENGTH = 2048
|
|
19
|
+
|
|
20
|
+
# Hosts for which plain http is acceptable (local development).
|
|
21
|
+
_LOCALHOST_HOSTS = ('localhost', '127.0.0.1', '::1')
|
|
22
|
+
|
|
23
|
+
# The browser supplies a token endpoint derived from the notebook's provider
|
|
24
|
+
# configuration, but the server never blindly trusts it: the authorization code
|
|
25
|
+
# and PKCE verifier must only ever be sent to an endpoint the server operator
|
|
26
|
+
# vouches for. Google's endpoint is fixed and allowed by default. For any other
|
|
27
|
+
# provider (Keycloak, Auth0, other OIDC, local development), the operator sets
|
|
28
|
+
# IPYAUTH_TOKEN_ENDPOINT on the Jupyter server; when set, that value is
|
|
29
|
+
# authoritative and the browser-supplied endpoint is ignored.
|
|
30
|
+
_GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
|
31
|
+
_TOKEN_ENDPOINT_ENV = 'IPYAUTH_TOKEN_ENDPOINT'
|
|
32
|
+
|
|
33
|
+
_TOKEN_ENDPOINT_NOT_CONFIGURED_MSG = (
|
|
34
|
+
'Token exchange endpoint not configured. Set the IPYAUTH_TOKEN_ENDPOINT '
|
|
35
|
+
'environment variable on the Jupyter server to your provider\'s token '
|
|
36
|
+
'endpoint (for example '
|
|
37
|
+
'https://your-provider/realms/your-realm/protocol/openid-connect/token). '
|
|
38
|
+
'Google\'s endpoint is allowed without configuration.'
|
|
39
|
+
)
|
|
40
|
+
_TOKEN_ENDPOINT_MISCONFIGURED_MSG = (
|
|
41
|
+
'Server misconfiguration: IPYAUTH_TOKEN_ENDPOINT is set to an invalid or '
|
|
42
|
+
'unsafe token endpoint. It must be an HTTPS URL (http is allowed only for '
|
|
43
|
+
'localhost) with no credentials, query string, or fragment.'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sanitize_log_value(value):
|
|
48
|
+
"""Strip C0/C1 control characters to prevent log injection."""
|
|
49
|
+
return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_valid_https_url(url):
|
|
53
|
+
"""Validate that a URL uses HTTPS and has no private/loopback host.
|
|
54
|
+
|
|
55
|
+
Localhost is allowed (for local development with e.g. Keycloak),
|
|
56
|
+
but only over http or https — not other schemes.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
parsed = urllib.parse.urlparse(url)
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
host = parsed.hostname
|
|
64
|
+
if not host:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# Allow localhost for local development (http or https)
|
|
68
|
+
if host in _LOCALHOST_HOSTS:
|
|
69
|
+
return parsed.scheme in ('http', 'https')
|
|
70
|
+
|
|
71
|
+
# Non-localhost must be HTTPS
|
|
72
|
+
if parsed.scheme != 'https':
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Block cloud metadata endpoints
|
|
76
|
+
blocked = ('169.254.169.254', 'metadata.google.internal')
|
|
77
|
+
if host in blocked:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
|
81
|
+
try:
|
|
82
|
+
ip = ipaddress.ip_address(host)
|
|
83
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
|
84
|
+
return False
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass # Not an IP literal — hostname is fine
|
|
87
|
+
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_token_endpoint(url):
|
|
92
|
+
"""Return a canonical endpoint URL or ``None`` when it is unsafe.
|
|
93
|
+
|
|
94
|
+
Token endpoints are exact allowlist entries, so credentials and URL
|
|
95
|
+
components that could change the request target are never accepted.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(url, str) or not url or url != url.strip():
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
parsed = urllib.parse.urlsplit(url)
|
|
102
|
+
# Accessing .port also validates malformed port values.
|
|
103
|
+
port = parsed.port
|
|
104
|
+
except (ValueError, TypeError):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
parsed.username is not None
|
|
109
|
+
or parsed.password is not None
|
|
110
|
+
or parsed.query
|
|
111
|
+
or parsed.fragment
|
|
112
|
+
or not parsed.hostname
|
|
113
|
+
or not _is_valid_https_url(url)
|
|
114
|
+
):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
hostname = parsed.hostname.lower()
|
|
118
|
+
if ':' in hostname:
|
|
119
|
+
netloc = f'[{hostname}]'
|
|
120
|
+
else:
|
|
121
|
+
netloc = hostname
|
|
122
|
+
if port is not None:
|
|
123
|
+
netloc += f':{port}'
|
|
124
|
+
|
|
125
|
+
return urllib.parse.urlunsplit((parsed.scheme.lower(), netloc, parsed.path, '', ''))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _get_configured_token_endpoint():
|
|
129
|
+
"""Return the operator-configured token endpoint, or ``None`` if unset.
|
|
130
|
+
|
|
131
|
+
Reads IPYAUTH_TOKEN_ENDPOINT from the server environment. Blank/whitespace
|
|
132
|
+
values are treated as unset.
|
|
133
|
+
"""
|
|
134
|
+
return os.environ.get(_TOKEN_ENDPOINT_ENV, '').strip() or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_token_endpoint(browser_supplied):
|
|
138
|
+
"""Decide which token endpoint the server will POST the code to.
|
|
139
|
+
|
|
140
|
+
Precedence:
|
|
141
|
+
1. If IPYAUTH_TOKEN_ENDPOINT is configured, use it (the browser-supplied
|
|
142
|
+
value is ignored). A misconfigured value is a server error.
|
|
143
|
+
2. Otherwise, allow the built-in Google endpoint if the browser supplied
|
|
144
|
+
exactly that.
|
|
145
|
+
3. Otherwise, reject with an actionable "not configured" error.
|
|
146
|
+
|
|
147
|
+
Returns (endpoint, None) on success or (None, (status, error_json)) on
|
|
148
|
+
failure. The returned endpoint has already passed the SSRF/normalization
|
|
149
|
+
checks in ``_normalize_token_endpoint``.
|
|
150
|
+
"""
|
|
151
|
+
configured = _get_configured_token_endpoint()
|
|
152
|
+
if configured is not None:
|
|
153
|
+
normalized = _normalize_token_endpoint(configured)
|
|
154
|
+
if normalized is None:
|
|
155
|
+
# The operator set an invalid/unsafe endpoint — 500, not the
|
|
156
|
+
# client's fault. Do not echo the raw value back.
|
|
157
|
+
return None, (500, _make_error(_TOKEN_ENDPOINT_MISCONFIGURED_MSG))
|
|
158
|
+
return normalized, None
|
|
159
|
+
|
|
160
|
+
# No server configuration: only the fixed, well-known Google endpoint is
|
|
161
|
+
# accepted without an explicit setting.
|
|
162
|
+
normalized = _normalize_token_endpoint(browser_supplied)
|
|
163
|
+
if normalized is not None and normalized == _GOOGLE_TOKEN_ENDPOINT:
|
|
164
|
+
return normalized, None
|
|
165
|
+
|
|
166
|
+
return None, (400, _make_error(_TOKEN_ENDPOINT_NOT_CONFIGURED_MSG))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _make_error(error_description):
|
|
170
|
+
"""Build a JSON error response body."""
|
|
171
|
+
return json.dumps({'error': 'invalid_request', 'error_description': error_description})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _validate_token_exchange_body(body):
|
|
175
|
+
"""Validate the token exchange request body.
|
|
176
|
+
|
|
177
|
+
Returns (fields_dict, None) on success or (None, (status, error_json)) on failure.
|
|
178
|
+
"""
|
|
179
|
+
required_fields = ['code', 'code_verifier', 'redirect_uri', 'token_endpoint', 'client_id']
|
|
180
|
+
for field in required_fields:
|
|
181
|
+
if field not in body:
|
|
182
|
+
return None, (400, _make_error(f'Missing required field: {field}'))
|
|
183
|
+
|
|
184
|
+
# Validate field types and lengths
|
|
185
|
+
for field in required_fields:
|
|
186
|
+
if not isinstance(body[field], str) or len(body[field]) > _MAX_FIELD_LENGTH:
|
|
187
|
+
return None, (400, _make_error(f'Invalid value for field: {field}'))
|
|
188
|
+
|
|
189
|
+
token_endpoint = body['token_endpoint']
|
|
190
|
+
redirect_uri = body['redirect_uri']
|
|
191
|
+
code_verifier = body['code_verifier']
|
|
192
|
+
|
|
193
|
+
# Decide the token endpoint server-side. The browser-supplied value is only
|
|
194
|
+
# honored when it is the built-in Google endpoint; otherwise the operator
|
|
195
|
+
# must configure IPYAUTH_TOKEN_ENDPOINT, which then takes precedence.
|
|
196
|
+
resolved_endpoint, endpoint_error = _resolve_token_endpoint(token_endpoint)
|
|
197
|
+
if endpoint_error:
|
|
198
|
+
return None, endpoint_error
|
|
199
|
+
|
|
200
|
+
# Validate redirect_uri: https, or http only for localhost.
|
|
201
|
+
try:
|
|
202
|
+
redir_parsed = urllib.parse.urlparse(redirect_uri)
|
|
203
|
+
redir_host = redir_parsed.hostname
|
|
204
|
+
except ValueError:
|
|
205
|
+
redir_parsed, redir_host = None, None
|
|
206
|
+
if redir_parsed is None or not (
|
|
207
|
+
redir_parsed.scheme == 'https'
|
|
208
|
+
or (redir_parsed.scheme == 'http' and redir_host in _LOCALHOST_HOSTS)
|
|
209
|
+
):
|
|
210
|
+
return None, (400, _make_error('redirect_uri must be HTTPS (or http://localhost for development)'))
|
|
211
|
+
|
|
212
|
+
# Validate code_verifier format per RFC 7636
|
|
213
|
+
if not _CODE_VERIFIER_RE.match(code_verifier):
|
|
214
|
+
return None, (400, _make_error('code_verifier does not match RFC 7636 format'))
|
|
215
|
+
|
|
216
|
+
fields = {f: body[f] for f in required_fields}
|
|
217
|
+
# Pin the server-resolved endpoint so the exchange always targets a trusted
|
|
218
|
+
# URL, never the raw browser-supplied value.
|
|
219
|
+
fields['token_endpoint'] = resolved_endpoint
|
|
220
|
+
return fields, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_BAD_GATEWAY = json.dumps({'error': 'bad_gateway', 'error_description': 'Token exchange failed'})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TokenExchangeHandler(JupyterHandler):
|
|
227
|
+
"""Exchanges an authorization code for tokens server-side (PKCE flow)."""
|
|
228
|
+
|
|
229
|
+
def _write_json(self, status, body):
|
|
230
|
+
self.set_status(status)
|
|
231
|
+
self.set_header('Content-Type', 'application/json')
|
|
232
|
+
self.write(body)
|
|
233
|
+
|
|
234
|
+
@tornado.web.authenticated
|
|
235
|
+
async def post(self):
|
|
236
|
+
try:
|
|
237
|
+
body = json.loads(self.request.body)
|
|
238
|
+
except (json.JSONDecodeError, TypeError):
|
|
239
|
+
self._write_json(400, _make_error('Invalid JSON body'))
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
fields, error = _validate_token_exchange_body(body)
|
|
243
|
+
if error:
|
|
244
|
+
self._write_json(*error)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
payload = {
|
|
248
|
+
'grant_type': 'authorization_code',
|
|
249
|
+
'code': fields['code'],
|
|
250
|
+
'code_verifier': fields['code_verifier'],
|
|
251
|
+
'client_id': fields['client_id'],
|
|
252
|
+
'redirect_uri': fields['redirect_uri'],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
http_client = tornado.httpclient.AsyncHTTPClient()
|
|
256
|
+
try:
|
|
257
|
+
response = await http_client.fetch(
|
|
258
|
+
fields['token_endpoint'],
|
|
259
|
+
method='POST',
|
|
260
|
+
body=urllib.parse.urlencode(payload),
|
|
261
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
262
|
+
)
|
|
263
|
+
self._write_json(200, response.body)
|
|
264
|
+
except tornado.httpclient.HTTPClientError as e:
|
|
265
|
+
if e.response and e.response.body:
|
|
266
|
+
# IdP returned an error response — forward it as 400
|
|
267
|
+
self._write_json(400, e.response.body)
|
|
268
|
+
else:
|
|
269
|
+
# Network / connection error — 502
|
|
270
|
+
self.log.exception('Token exchange network error')
|
|
271
|
+
self._write_json(502, _BAD_GATEWAY)
|
|
272
|
+
except Exception:
|
|
273
|
+
self.log.exception('Token exchange error')
|
|
274
|
+
self._write_json(502, _BAD_GATEWAY)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Static callback files, served verbatim. The whitelist prevents path traversal.
|
|
278
|
+
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates')
|
|
279
|
+
_ALLOWED_ASSETS = {'main.js', 'util.js'}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _read_static(*parts):
|
|
283
|
+
with open(os.path.join(_TEMPLATES_DIR, *parts), 'rb') as f:
|
|
284
|
+
return f.read()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class CallbackHandler(JupyterHandler):
|
|
288
|
+
"""Serves the OAuth callback HTML page.
|
|
289
|
+
|
|
290
|
+
Unauthenticated: this is the OAuth redirect target — the IdP redirects
|
|
291
|
+
the user here after login, so Jupyter auth must not gate access.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
@allow_unauthenticated
|
|
295
|
+
def get(self, path):
|
|
296
|
+
self.log.info('in CallbackHandler with path=%s', _sanitize_log_value(path))
|
|
297
|
+
self.set_header('Content-Type', 'text/html')
|
|
298
|
+
self.set_header('Content-Security-Policy', "frame-ancestors 'self'")
|
|
299
|
+
self.set_header('X-Frame-Options', 'SAMEORIGIN')
|
|
300
|
+
self.write(_read_static('index.html'))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class CallbackAssetsHandler(JupyterHandler):
|
|
304
|
+
"""Serves JS assets for the callback page.
|
|
305
|
+
|
|
306
|
+
Unauthenticated: these assets are loaded by the OAuth callback page,
|
|
307
|
+
which itself must be accessible without Jupyter auth.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
@allow_unauthenticated
|
|
311
|
+
def get(self, path):
|
|
312
|
+
if path not in _ALLOWED_ASSETS:
|
|
313
|
+
self.set_status(404)
|
|
314
|
+
return
|
|
315
|
+
self.log.info('in CallbackAssetsHandler with path=%s', _sanitize_log_value(path))
|
|
316
|
+
self.set_header('Content-Type', 'application/javascript')
|
|
317
|
+
self.write(_read_static('assets', path))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _load_jupyter_server_extension(nb_app):
|
|
321
|
+
web_app = nb_app.web_app
|
|
322
|
+
base_url = web_app.settings['base_url']
|
|
323
|
+
|
|
324
|
+
# '/callback(.*)' also matches '/callback.html'. The root '/assets/' route is
|
|
325
|
+
# needed when the redirect_uri has no trailing slash ('/callback'), because
|
|
326
|
+
# index.html loads its scripts via relative 'assets/...' URLs.
|
|
327
|
+
web_app.add_handlers(
|
|
328
|
+
'.*$',
|
|
329
|
+
[(url_path_join(base_url, '/callback/token-exchange'), TokenExchangeHandler),
|
|
330
|
+
(url_path_join(base_url, '/callback/assets/(.*)'), CallbackAssetsHandler),
|
|
331
|
+
(url_path_join(base_url, '/assets/(.*)'), CallbackAssetsHandler),
|
|
332
|
+
(url_path_join(base_url, '/callback(.*)'), CallbackHandler),
|
|
333
|
+
]
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
nb_app.log.info("ipyauth callback server extension enabled")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Callback page orchestration (classic <script>, runs on load).
|
|
2
|
+
//
|
|
3
|
+
// The decision logic (state validation + error/success discrimination) lives in
|
|
4
|
+
// util.js as buildCallbackMessage() so it can be unit-tested directly. This file
|
|
5
|
+
// is the thin wire-up: read the URL and the expected state, decide, then display,
|
|
6
|
+
// post the result back to the widget, and auto-close.
|
|
7
|
+
|
|
8
|
+
// extract urlData
|
|
9
|
+
const urlData = getDataFromCallbackUrl();
|
|
10
|
+
|
|
11
|
+
// Validate the exact OAuth state against the value copied into this popup's
|
|
12
|
+
// sessionStorage when the popup browsing context was created.
|
|
13
|
+
const expectedState = sessionStorage.getItem('ipyauth_expected_state');
|
|
14
|
+
|
|
15
|
+
// Clean up the stored state regardless of outcome
|
|
16
|
+
sessionStorage.removeItem('ipyauth_expected_state');
|
|
17
|
+
|
|
18
|
+
// Decide the outcome (pure, testable).
|
|
19
|
+
const { kind, objMsg } = buildCallbackMessage(urlData, expectedState);
|
|
20
|
+
|
|
21
|
+
if (kind === 'state_mismatch') {
|
|
22
|
+
// State mismatch — possible CSRF attack
|
|
23
|
+
display('Authentication failed: state mismatch.', 'msg', true);
|
|
24
|
+
} else if (kind === 'error') {
|
|
25
|
+
// error in authentication
|
|
26
|
+
display('Authentication failed.', 'msg', true);
|
|
27
|
+
if (urlData.error_description) {
|
|
28
|
+
display(urlData.error_description, 'msg');
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// A valid callback contains an authorization code.
|
|
32
|
+
display('Authentication completed.', 'msg');
|
|
33
|
+
display('Authorization code received.', 'msg');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// post message back to opener window (popup flow)
|
|
37
|
+
sendMessageToParent(window, objMsg);
|
|
38
|
+
|
|
39
|
+
// Auto-close the popup after a short delay.
|
|
40
|
+
// In the normal flow, the widget closes the popup via popupWindowRef.close().
|
|
41
|
+
// But when the popup was blocked and later allowed by the browser,
|
|
42
|
+
// popupWindowRef is null, so the widget can't close it. The popup closes itself.
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
display('Closing...', 'msg');
|
|
45
|
+
window.close();
|
|
46
|
+
}, 1500);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
function display(obj, id = 'authStatus', reset = false) {
|
|
2
|
+
const e = document.getElementById(id);
|
|
3
|
+
const line = document.createElement('span');
|
|
4
|
+
line.textContent = String(obj);
|
|
5
|
+
if (reset) {
|
|
6
|
+
e.textContent = '';
|
|
7
|
+
}
|
|
8
|
+
e.appendChild(line);
|
|
9
|
+
e.appendChild(document.createElement('br'));
|
|
10
|
+
e.appendChild(document.createElement('br'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
///////////////////////////////////////////////////////
|
|
14
|
+
|
|
15
|
+
function getDataFromCallbackUrl(href) {
|
|
16
|
+
const source = typeof href === 'string' ? href : window.location.href;
|
|
17
|
+
const url1 = source.split('#')[1];
|
|
18
|
+
const url2 = source.split('?')[1];
|
|
19
|
+
const url = url1 ? url1 : url2;
|
|
20
|
+
if (!url) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
const urlParams = new URLSearchParams(url);
|
|
24
|
+
const entries = Array.from(urlParams.entries());
|
|
25
|
+
// Validate parameters: reject values exceeding 4096 chars or containing control characters
|
|
26
|
+
const validEntries = entries.filter(([k, v]) =>
|
|
27
|
+
v.length <= 4096 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(v)
|
|
28
|
+
);
|
|
29
|
+
if (validEntries.length === 0) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
const data = Object.assign(
|
|
33
|
+
...validEntries.map(([k, v]) => ({ [k]: v }))
|
|
34
|
+
);
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function containsError(urlData) {
|
|
39
|
+
if ('error' in urlData || 'error_description' in urlData) return true;
|
|
40
|
+
if ('access_token' in urlData || 'id_token' in urlData) return true;
|
|
41
|
+
if (!('code' in urlData) || !urlData.code) return true;
|
|
42
|
+
if (!('state' in urlData)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getBroadcastChannelName(state) {
|
|
47
|
+
if (typeof state !== 'string' || state.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return `ipyauth-${encodeURIComponent(state)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendMessageToParent(window, objMsg) {
|
|
54
|
+
// Derive targetOrigin from opener's origin or fall back to callback page origin
|
|
55
|
+
let targetOrigin;
|
|
56
|
+
try {
|
|
57
|
+
targetOrigin = window.opener && window.opener.origin ? window.opener.origin : window.location.origin;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Cross-origin access to opener.origin may throw; fall back to callback page origin
|
|
60
|
+
targetOrigin = window.location.origin;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (window.opener) {
|
|
64
|
+
window.opener.postMessage(objMsg, targetOrigin);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// BroadcastChannel fallback — use a per-flow channel when the opener
|
|
68
|
+
// relationship is unavailable (for example, after a COOP boundary).
|
|
69
|
+
const channelName = getBroadcastChannelName(objMsg && objMsg.state);
|
|
70
|
+
if (channelName) {
|
|
71
|
+
const bc = new BroadcastChannel(channelName);
|
|
72
|
+
bc.postMessage(objMsg);
|
|
73
|
+
bc.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build an error message carrying only the non-sensitive OAuth error fields.
|
|
78
|
+
function buildErrorMessage(data) {
|
|
79
|
+
const message = { statusAuth: 'error' };
|
|
80
|
+
for (const key of ['state', 'error', 'error_description', 'error_uri']) {
|
|
81
|
+
if (data && typeof data[key] === 'string') {
|
|
82
|
+
message[key] = data[key];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return message;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Pure decision logic for the callback page: given the parsed callback URL data
|
|
89
|
+
// and the OAuth state expected by this flow, decide what postMessage payload to
|
|
90
|
+
// send back to the widget. This is the security-relevant heart of the callback
|
|
91
|
+
// (state validation + error/success discrimination) and is extracted here so it
|
|
92
|
+
// can be unit-tested directly instead of being reimplemented in tests.
|
|
93
|
+
//
|
|
94
|
+
// Returns { kind, objMsg } where kind is one of:
|
|
95
|
+
// 'state_mismatch' — expected/actual state did not match (possible CSRF)
|
|
96
|
+
// 'error' — provider returned an OAuth error (or a rejected response)
|
|
97
|
+
// 'ok' — a valid authorization code was received
|
|
98
|
+
function buildCallbackMessage(urlData, expectedState) {
|
|
99
|
+
const data = urlData || {};
|
|
100
|
+
|
|
101
|
+
const stateValid =
|
|
102
|
+
typeof expectedState === 'string' &&
|
|
103
|
+
typeof data.state === 'string' &&
|
|
104
|
+
data.state === expectedState;
|
|
105
|
+
|
|
106
|
+
if (!stateValid) {
|
|
107
|
+
return {
|
|
108
|
+
kind: 'state_mismatch',
|
|
109
|
+
objMsg: buildErrorMessage({ state: data.state, error: 'state_mismatch' }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (containsError(data)) {
|
|
114
|
+
return { kind: 'error', objMsg: buildErrorMessage(data) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
kind: 'ok',
|
|
119
|
+
objMsg: { statusAuth: 'ok', code: data.code, state: data.state },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Dual-mode export: in the browser these functions are plain globals defined by
|
|
124
|
+
// this classic <script>; under a CommonJS test runner (Jest) they are also
|
|
125
|
+
// importable so tests exercise the real callback code rather than a copy.
|
|
126
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
127
|
+
module.exports = {
|
|
128
|
+
display,
|
|
129
|
+
getDataFromCallbackUrl,
|
|
130
|
+
containsError,
|
|
131
|
+
getBroadcastChannelName,
|
|
132
|
+
sendMessageToParent,
|
|
133
|
+
buildErrorMessage,
|
|
134
|
+
buildCallbackMessage,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<title>ipyauth-pkce Callback</title>
|
|
7
|
+
</head>
|
|
8
|
+
|
|
9
|
+
<body>
|
|
10
|
+
<h3>ipyauth-pkce Callback</h3>
|
|
11
|
+
<div id="msg"></div>
|
|
12
|
+
<script type="text/javascript" src="assets/util.js"></script>
|
|
13
|
+
<script type="text/javascript" src="assets/main.js"></script>
|
|
14
|
+
</body>
|
|
15
|
+
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
# Must match the JavaScript providers in ipyauth_pkce/js/src/providers/index.js.
|
|
4
|
+
VALID_ID_PROVIDERS = ['auth0', 'google', 'keycloak']
|
|
5
|
+
|
|
6
|
+
_IMG_DIR = os.path.join(os.path.dirname(__file__), 'img')
|
|
7
|
+
DIC_LOGO = {name: os.path.join(_IMG_DIR, f'{name}-logo.png') for name in VALID_ID_PROVIDERS}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from traitlets import Unicode
|
|
2
|
+
|
|
3
|
+
from ._params_base import ParamsBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParamsAuth0(ParamsBase):
|
|
7
|
+
"""
|
|
8
|
+
See Auth0 doc https://auth0.com/docs/api/authentication#authorize-application
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_params_fields = ('name', 'authorize_endpoint', 'token_endpoint')
|
|
12
|
+
_url_params_fields = ('response_type', 'client_id', 'redirect_uri', 'audience', 'scope')
|
|
13
|
+
|
|
14
|
+
domain = Unicode()
|
|
15
|
+
audience = Unicode()
|
|
16
|
+
|
|
17
|
+
def __init__(self,
|
|
18
|
+
name='auth0',
|
|
19
|
+
response_type=None,
|
|
20
|
+
domain=None,
|
|
21
|
+
client_id=None,
|
|
22
|
+
redirect_uri=None,
|
|
23
|
+
audience=None,
|
|
24
|
+
scope=None,
|
|
25
|
+
|
|
26
|
+
dotenv_folder='.',
|
|
27
|
+
dotenv_file=None,
|
|
28
|
+
):
|
|
29
|
+
super().__init__(name, dotenv_folder, dotenv_file,
|
|
30
|
+
response_type=response_type, domain=domain, client_id=client_id,
|
|
31
|
+
redirect_uri=redirect_uri, audience=audience, scope=scope)
|
|
32
|
+
|
|
33
|
+
def _endpoints(self):
|
|
34
|
+
return {
|
|
35
|
+
'authorize_endpoint': f'https://{self.domain}/authorize',
|
|
36
|
+
'token_endpoint': f'https://{self.domain}/oauth/token',
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from traitlets import HasTraits, TraitError, Unicode, validate
|
|
4
|
+
|
|
5
|
+
from ._util import is_url, load_dotenv
|
|
6
|
+
|
|
7
|
+
_URL_RULE = 'an https url (http is allowed only for localhost)'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParamsBase(HasTraits):
|
|
11
|
+
"""Shared behavior of the provider parameter classes.
|
|
12
|
+
|
|
13
|
+
Values are taken from the optional dotenv file first, then overridden by any
|
|
14
|
+
truthy constructor argument. Subclasses declare which attributes end up in
|
|
15
|
+
``data`` (top level vs ``url_params``) and compute their endpoints.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_params_fields = ()
|
|
19
|
+
_url_params_fields = ()
|
|
20
|
+
|
|
21
|
+
name = Unicode()
|
|
22
|
+
response_type = Unicode('code')
|
|
23
|
+
client_id = Unicode()
|
|
24
|
+
redirect_uri = Unicode()
|
|
25
|
+
scope = Unicode()
|
|
26
|
+
|
|
27
|
+
def __init__(self, name, dotenv_folder, dotenv_file, **overrides):
|
|
28
|
+
super().__init__()
|
|
29
|
+
for k, v in load_dotenv(dotenv_folder, dotenv_file, name).items():
|
|
30
|
+
setattr(self, k, v)
|
|
31
|
+
self.name = name
|
|
32
|
+
for k, v in overrides.items():
|
|
33
|
+
if v:
|
|
34
|
+
setattr(self, k, v)
|
|
35
|
+
for k, v in self._endpoints().items():
|
|
36
|
+
setattr(self, k, v)
|
|
37
|
+
self.data = self.build_data()
|
|
38
|
+
|
|
39
|
+
def _endpoints(self):
|
|
40
|
+
"""Return the provider endpoints derived from the configured values."""
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def build_data(self):
|
|
44
|
+
"""Build the provider config dict, keeping only non-empty values."""
|
|
45
|
+
def pick(fields):
|
|
46
|
+
return {k: getattr(self, k, '') for k in fields if getattr(self, k, '') != ''}
|
|
47
|
+
|
|
48
|
+
data = pick(self._params_fields)
|
|
49
|
+
data['url_params'] = pick(self._url_params_fields)
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
def __repr__(self):
|
|
53
|
+
return json.dumps(self.data, sort_keys=False, indent=2)
|
|
54
|
+
|
|
55
|
+
@validate('response_type')
|
|
56
|
+
def _valid_response_type(self, proposal):
|
|
57
|
+
"""Require the authorization-code response type for PKCE."""
|
|
58
|
+
if proposal['value'] != 'code':
|
|
59
|
+
raise TraitError('response_type must be "code"')
|
|
60
|
+
return proposal['value']
|
|
61
|
+
|
|
62
|
+
@validate('redirect_uri')
|
|
63
|
+
def _valid_redirect_uri(self, proposal):
|
|
64
|
+
if not is_url(proposal['value']):
|
|
65
|
+
raise TraitError(f'redirect_uri must be {_URL_RULE}')
|
|
66
|
+
return proposal['value']
|
|
67
|
+
|
|
68
|
+
@validate('scope')
|
|
69
|
+
def _valid_scope(self, proposal):
|
|
70
|
+
"""Require every claimed-mandatory scope to be present."""
|
|
71
|
+
if not {'openid', 'profile'}.issubset(proposal['value'].split(' ')):
|
|
72
|
+
raise TraitError('scope must contain "openid" and "profile"')
|
|
73
|
+
return proposal['value']
|