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.
Files changed (30) hide show
  1. ipyauth_pkce/__init__.py +10 -0
  2. ipyauth_pkce/ipyauth_callback/__init__.py +2 -0
  3. ipyauth_pkce/ipyauth_callback/server_extension.py +336 -0
  4. ipyauth_pkce/ipyauth_callback/templates/assets/main.js +46 -0
  5. ipyauth_pkce/ipyauth_callback/templates/assets/util.js +136 -0
  6. ipyauth_pkce/ipyauth_callback/templates/index.html +16 -0
  7. ipyauth_pkce/ipyauth_widget/__init__.py +7 -0
  8. ipyauth_pkce/ipyauth_widget/_config.py +7 -0
  9. ipyauth_pkce/ipyauth_widget/_params_auth0.py +37 -0
  10. ipyauth_pkce/ipyauth_widget/_params_base.py +73 -0
  11. ipyauth_pkce/ipyauth_widget/_params_google.py +39 -0
  12. ipyauth_pkce/ipyauth_widget/_params_keycloak.py +45 -0
  13. ipyauth_pkce/ipyauth_widget/_util.py +95 -0
  14. ipyauth_pkce/ipyauth_widget/_widget_box.py +174 -0
  15. ipyauth_pkce/ipyauth_widget/img/auth0-logo.png +0 -0
  16. ipyauth_pkce/ipyauth_widget/img/google-logo.png +0 -0
  17. ipyauth_pkce/ipyauth_widget/img/keycloak-logo.png +0 -0
  18. ipyauth_pkce-1.0.0.data/data/etc/jupyter/jupyter_server_config.d/ipyauth-pkce.json +7 -0
  19. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/install.json +5 -0
  20. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/package.json +73 -0
  21. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/218.f367b7ba1b52d7bdfccd.js +1 -0
  22. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/442.460d9c3da800991e4a71.js +2 -0
  23. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/442.460d9c3da800991e4a71.js.LICENSE.txt +10 -0
  24. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/remoteEntry.68f8fd1f6638911fce79.js +1 -0
  25. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/style.js +4 -0
  26. ipyauth_pkce-1.0.0.data/data/share/jupyter/labextensions/ipyauth-pkce/static/third-party-licenses.json +34 -0
  27. ipyauth_pkce-1.0.0.dist-info/METADATA +239 -0
  28. ipyauth_pkce-1.0.0.dist-info/RECORD +30 -0
  29. ipyauth_pkce-1.0.0.dist-info/WHEEL +4 -0
  30. ipyauth_pkce-1.0.0.dist-info/licenses/LICENSE +22 -0
@@ -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,2 @@
1
+ def _jupyter_server_extension_points():
2
+ return [{'module': 'ipyauth_pkce.ipyauth_callback.server_extension'}]
@@ -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
+ from ._widget_box import Auth
2
+
3
+ from ._params_auth0 import ParamsAuth0
4
+ from ._params_google import ParamsGoogle
5
+ from ._params_keycloak import ParamsKeycloak
6
+
7
+ __all__ = ['Auth', 'ParamsAuth0', 'ParamsGoogle', 'ParamsKeycloak']
@@ -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']