csiapps 0.1.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.
- csiapps/__init__.py +54 -0
- csiapps/app.py +375 -0
- csiapps/auth.py +194 -0
- csiapps/client.py +521 -0
- csiapps/config.py +164 -0
- csiapps/sandbox.py +623 -0
- csiapps-0.1.0.dist-info/METADATA +86 -0
- csiapps-0.1.0.dist-info/RECORD +10 -0
- csiapps-0.1.0.dist-info/WHEEL +4 -0
- csiapps-0.1.0.dist-info/licenses/LICENSE +21 -0
csiapps/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""csiapps: Python port of the CSIO ``csiapps`` R package.
|
|
2
|
+
|
|
3
|
+
Helper functions and utilities for CSI data warehouse ingestion and Shiny
|
|
4
|
+
(for Python) web applications.
|
|
5
|
+
|
|
6
|
+
The public API mirrors the R package's ``NAMESPACE`` and lands module by module
|
|
7
|
+
across the porting phases (see ``PORTING_PLAN.md``):
|
|
8
|
+
|
|
9
|
+
* ``config`` -- set_institute, is_sandbox_mode (phase 2)
|
|
10
|
+
* ``auth`` -- check_secrets, PKCE, token exchange (phase 2)
|
|
11
|
+
* ``client`` -- make_request, fetch_org_options/profiles (phase 3)
|
|
12
|
+
* ``sandbox`` -- register_sandbox_schema, create_*, ... (phase 4)
|
|
13
|
+
* ``app`` -- ui_wrapper, server_wrapper (phase 5)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .app import server_wrapper, ui_wrapper
|
|
17
|
+
from .auth import check_secrets
|
|
18
|
+
from .client import (
|
|
19
|
+
fetch_org_options,
|
|
20
|
+
fetch_profile,
|
|
21
|
+
fetch_profiles,
|
|
22
|
+
flatten_profile,
|
|
23
|
+
make_request,
|
|
24
|
+
)
|
|
25
|
+
from .config import is_sandbox_mode, set_institute, set_sandbox_mode
|
|
26
|
+
from .sandbox import (
|
|
27
|
+
browse_sandbox,
|
|
28
|
+
clear_sandbox,
|
|
29
|
+
create_profile,
|
|
30
|
+
create_sport_org,
|
|
31
|
+
register_sandbox_schema,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"browse_sandbox",
|
|
39
|
+
"check_secrets",
|
|
40
|
+
"clear_sandbox",
|
|
41
|
+
"create_profile",
|
|
42
|
+
"create_sport_org",
|
|
43
|
+
"fetch_org_options",
|
|
44
|
+
"fetch_profile",
|
|
45
|
+
"fetch_profiles",
|
|
46
|
+
"flatten_profile",
|
|
47
|
+
"is_sandbox_mode",
|
|
48
|
+
"make_request",
|
|
49
|
+
"register_sandbox_schema",
|
|
50
|
+
"server_wrapper",
|
|
51
|
+
"set_institute",
|
|
52
|
+
"set_sandbox_mode",
|
|
53
|
+
"ui_wrapper",
|
|
54
|
+
]
|
csiapps/app.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Shiny for Python wrappers for CSIAPPS apps.
|
|
2
|
+
|
|
3
|
+
Port of ``R/shiny.R``: ``ui_wrapper`` (consistent navbar/footer chrome + auth
|
|
4
|
+
status + sandbox banner) and ``server_wrapper`` (OAuth2 PKCE login, or a
|
|
5
|
+
simulated login in sandbox mode).
|
|
6
|
+
|
|
7
|
+
Framework mapping notes (R Shiny -> Shiny for Python):
|
|
8
|
+
|
|
9
|
+
* ``reactiveVal`` -> ``reactive.value``; ``observe`` -> ``@reactive.effect``;
|
|
10
|
+
``observeEvent`` -> ``@reactive.effect`` + ``@reactive.event``;
|
|
11
|
+
``renderUI``/``uiOutput`` -> ``@render.ui``/``ui.output_ui``.
|
|
12
|
+
* ``shinyjs::runjs`` has no Python port; the two `window.location` nudges use a
|
|
13
|
+
custom-message handler (``csip_reset``) injected in the head, same mechanism as
|
|
14
|
+
the redirect (``csip_redirect``). ``useShinyjs()`` is dropped.
|
|
15
|
+
* ``session$userData$csiapps_token`` -> :func:`csiapps.client.set_session_token`
|
|
16
|
+
(keyed on the session), read back by :func:`csiapps.client.current_token`.
|
|
17
|
+
* ``session$sendCustomMessage`` is a coroutine here, so effects that send
|
|
18
|
+
messages are ``async``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import os
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from urllib.parse import parse_qs, urlencode
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from shiny import reactive, render, ui
|
|
28
|
+
from shiny.types import TagChild
|
|
29
|
+
from shiny.ui import Tag
|
|
30
|
+
|
|
31
|
+
from . import auth, client, config
|
|
32
|
+
|
|
33
|
+
_FAVICON = "https://csiontario.ca/wp-content/uploads/2022/04/cropped-CSIO-Favicon-192x192.png"
|
|
34
|
+
|
|
35
|
+
_HANDLERS_JS = """
|
|
36
|
+
Shiny.addCustomMessageHandler('csip_redirect', function(url) {
|
|
37
|
+
if (url && typeof url === 'string') { window.top.location.href = url; }
|
|
38
|
+
});
|
|
39
|
+
Shiny.addCustomMessageHandler('csip_reset', function(x) {
|
|
40
|
+
window.location.href = window.location.pathname;
|
|
41
|
+
});
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- chrome (navbar / footer / styles) ---------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _csi_chrome_styles():
|
|
49
|
+
# Neutral frame: white bar, CSI-red accent line, soft shadow. Scoped by id +
|
|
50
|
+
# !important so a wrapped app's theme/CSS cannot override it.
|
|
51
|
+
accent = "#d81f26"
|
|
52
|
+
bar_bg = "#ffffff"
|
|
53
|
+
bar_text = "#1f2937"
|
|
54
|
+
css = f"""
|
|
55
|
+
#csi-navbar {{
|
|
56
|
+
background-color: {bar_bg} !important;
|
|
57
|
+
border-bottom: 3px solid {accent} !important;
|
|
58
|
+
box-shadow: 0 2px 4px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
|
|
59
|
+
position: sticky;
|
|
60
|
+
top: 0;
|
|
61
|
+
z-index: 1030;
|
|
62
|
+
}}
|
|
63
|
+
#csi-navbar .navbar-brand,
|
|
64
|
+
#csi-navbar .navbar-brand:hover,
|
|
65
|
+
#csi-navbar .navbar-nav .nav-link {{
|
|
66
|
+
color: {bar_text} !important;
|
|
67
|
+
}}
|
|
68
|
+
#footer {{
|
|
69
|
+
background-color: {bar_bg} !important;
|
|
70
|
+
color: {bar_text} !important;
|
|
71
|
+
border-top: 1px solid #e6e6e6 !important;
|
|
72
|
+
z-index: 1030;
|
|
73
|
+
}}
|
|
74
|
+
#footer p, #footer a {{ color: {bar_text} !important; }}
|
|
75
|
+
"""
|
|
76
|
+
return ui.tags.style(ui.HTML(css))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _logo_src():
|
|
80
|
+
if config.get_institute() == "csipacific":
|
|
81
|
+
return "https://www.csipacific.ca/wp-content/uploads/2024/05/csi-pacific-logo-main.png"
|
|
82
|
+
return "https://csiontario.ca/wp-content/uploads/2022/03/logo-csi-ontario.png"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _navbar_ui():
|
|
86
|
+
return ui.tags.nav(
|
|
87
|
+
ui.tags.div(
|
|
88
|
+
ui.tags.a(
|
|
89
|
+
ui.tags.img(src=_logo_src(), height="48px", style="margin-right: 8px;"),
|
|
90
|
+
class_="navbar-brand d-flex align-items-center",
|
|
91
|
+
href="#",
|
|
92
|
+
),
|
|
93
|
+
class_="container-fluid",
|
|
94
|
+
),
|
|
95
|
+
id="csi-navbar",
|
|
96
|
+
class_="navbar navbar-expand-lg navbar-light bg-white px-3",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _footer_ui():
|
|
101
|
+
from datetime import date
|
|
102
|
+
|
|
103
|
+
name = "CSI Pacific" if config.get_institute() == "csipacific" else "CSI Ontario"
|
|
104
|
+
return ui.tags.footer(
|
|
105
|
+
ui.tags.div(
|
|
106
|
+
ui.tags.p(
|
|
107
|
+
ui.HTML(f"© {date.today().year} {name}"),
|
|
108
|
+
class_="col-md-4 mb-0",
|
|
109
|
+
),
|
|
110
|
+
ui.tags.ul(class_="nav col-md-4 justify-content-end"),
|
|
111
|
+
class_="d-flex flex-wrap justify-content-between align-items-center py-3 container",
|
|
112
|
+
),
|
|
113
|
+
id="footer",
|
|
114
|
+
class_="mt-4 bg-dark text-white border-top border-light fixed-bottom",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _sandbox_banner():
|
|
119
|
+
return ui.tags.div(
|
|
120
|
+
ui.HTML("Sandbox mode — not connected to the live warehouse"),
|
|
121
|
+
class_="text-center border-bottom",
|
|
122
|
+
style="background:#faf6ec;color:#8a6d3b;font-size:12px;padding:3px 0;letter-spacing:.02em;",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ui_wrapper(*args: TagChild, sandbox: bool | None = None) -> Tag:
|
|
127
|
+
"""Wrap an app's UI in the standard CSI chrome.
|
|
128
|
+
|
|
129
|
+
Adds the CSI navbar, footer, an auth-status line, the favicon and
|
|
130
|
+
redirect/reset message handlers, and — in sandbox mode — a banner making it
|
|
131
|
+
obvious the app is not connected to the live warehouse. Use it in place of
|
|
132
|
+
``ui.page_fluid`` at the top of an app's UI definition; pair it with
|
|
133
|
+
[`server_wrapper`][csiapps.app.server_wrapper] on the server side.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
*args: The app's own UI elements (Shiny tags / components), rendered
|
|
137
|
+
below the auth-status line inside the chrome.
|
|
138
|
+
sandbox: Force the sandbox banner on (``True``) or off (``False``).
|
|
139
|
+
``None`` (the default) resolves via
|
|
140
|
+
[`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A ``ui.page_fluid`` page containing the chrome and the supplied UI.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
```python
|
|
147
|
+
from shiny import ui
|
|
148
|
+
import csiapps
|
|
149
|
+
|
|
150
|
+
app_ui = csiapps.ui_wrapper(
|
|
151
|
+
ui.h2("My app"),
|
|
152
|
+
ui.input_action_button("logout", "Log out"),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Note:
|
|
157
|
+
The chrome styles are scoped by id and marked ``!important`` so a wrapped
|
|
158
|
+
app's own theme cannot override the navbar and footer. Include an
|
|
159
|
+
``input_action_button("logout", ...)`` for the logout effect wired up by
|
|
160
|
+
[`server_wrapper`][csiapps.app.server_wrapper].
|
|
161
|
+
"""
|
|
162
|
+
if sandbox is None:
|
|
163
|
+
sandbox = config.is_sandbox_mode()
|
|
164
|
+
|
|
165
|
+
children = [
|
|
166
|
+
ui.head_content(
|
|
167
|
+
ui.tags.script(ui.HTML(_HANDLERS_JS)),
|
|
168
|
+
ui.tags.link(rel="shortcut icon", href=_FAVICON),
|
|
169
|
+
),
|
|
170
|
+
_csi_chrome_styles(),
|
|
171
|
+
_navbar_ui(),
|
|
172
|
+
]
|
|
173
|
+
if sandbox:
|
|
174
|
+
children.append(_sandbox_banner())
|
|
175
|
+
# padding-bottom leaves room for the fixed-bottom footer so it never overlaps
|
|
176
|
+
# app content on short pages (mirrors the R wrapper's fluidPage style).
|
|
177
|
+
children.append(
|
|
178
|
+
ui.div(
|
|
179
|
+
ui.output_ui("auth_status"),
|
|
180
|
+
*args,
|
|
181
|
+
style="padding-bottom: 80px;",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
children.append(_footer_ui())
|
|
185
|
+
return ui.page_fluid(*children)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---- server ------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _seed_token_value():
|
|
192
|
+
"""The token value the sandbox seeds the session with (pure, so it is testable).
|
|
193
|
+
|
|
194
|
+
Mirrors R's .sandbox_seed_session(): the developer's existing access token is
|
|
195
|
+
adopted as the "granted" token; with none set, a sentinel marks the session
|
|
196
|
+
unauthenticated (the shared consumer then short-circuits before any network
|
|
197
|
+
call).
|
|
198
|
+
"""
|
|
199
|
+
tok = os.environ.get("CSIAPPS_ACCESS_TOKEN", "")
|
|
200
|
+
if tok:
|
|
201
|
+
return {"access_token": tok, "sandbox": True}
|
|
202
|
+
return {"sandbox": True, "unauthenticated": True}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _signed_in_text(userinfo, sandbox):
|
|
206
|
+
if userinfo and userinfo.get("first_name") and userinfo.get("last_name"):
|
|
207
|
+
text = f"Signed in as {userinfo['first_name']} {userinfo['last_name']}"
|
|
208
|
+
else:
|
|
209
|
+
text = "Signed in"
|
|
210
|
+
if sandbox:
|
|
211
|
+
text += " (sandbox)"
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def server_wrapper(
|
|
216
|
+
app_specific_logic: Callable, sandbox: bool | None = None
|
|
217
|
+
) -> Callable:
|
|
218
|
+
"""Wrap an app's server function with CSIAPPS authentication.
|
|
219
|
+
|
|
220
|
+
Returns a Shiny server function that handles login before delegating to your
|
|
221
|
+
own server logic. In production it runs the OAuth2 PKCE flow (redirect to
|
|
222
|
+
CSIAPPS, exchange the returned code for a token, load ``/me`` for the header).
|
|
223
|
+
In sandbox mode it simulates that login using ``CSIAPPS_ACCESS_TOKEN`` if
|
|
224
|
+
set, or marks the session unauthenticated otherwise. Either way the
|
|
225
|
+
per-session token is stored so [`make_request`][csiapps.client.make_request]
|
|
226
|
+
and the ``fetch_*`` helpers pick it up automatically.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
app_specific_logic: Your app's server function with the usual Shiny
|
|
230
|
+
``(input, output, session)`` signature. It is called after auth is
|
|
231
|
+
wired up, keeping its own lexical scope.
|
|
232
|
+
sandbox: Force sandbox (``True``) or production (``False``) auth
|
|
233
|
+
behaviour. ``None`` (the default) resolves via
|
|
234
|
+
[`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Callable: A server function to hand to Shiny's ``App(app_ui, server)``.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
```python
|
|
241
|
+
from shiny import App
|
|
242
|
+
import csiapps
|
|
243
|
+
|
|
244
|
+
def my_server(input, output, session):
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
app = App(app_ui, csiapps.server_wrapper(my_server))
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Note:
|
|
251
|
+
The wrapper registers a logout effect bound to an ``input.logout``
|
|
252
|
+
action button — include one in the UI (see
|
|
253
|
+
[`ui_wrapper`][csiapps.app.ui_wrapper]). Blocking token and ``/me`` calls
|
|
254
|
+
run off the event loop so a slow endpoint cannot stall other sessions.
|
|
255
|
+
"""
|
|
256
|
+
if sandbox is None:
|
|
257
|
+
sandbox = config.is_sandbox_mode()
|
|
258
|
+
|
|
259
|
+
def server(input, output, session):
|
|
260
|
+
user_token = reactive.value(None)
|
|
261
|
+
userinfo = reactive.value(None)
|
|
262
|
+
|
|
263
|
+
if sandbox:
|
|
264
|
+
# Simulate the redirect: seed the token from the environment and hand
|
|
265
|
+
# it to the same consumer a production login would.
|
|
266
|
+
user_token.set(_seed_token_value())
|
|
267
|
+
else:
|
|
268
|
+
|
|
269
|
+
@reactive.effect
|
|
270
|
+
async def _oauth():
|
|
271
|
+
qs = parse_qs(session.clientdata.url_search().lstrip("?"))
|
|
272
|
+
code = qs.get("code", [None])[0]
|
|
273
|
+
state = qs.get("state", [None])[0]
|
|
274
|
+
err = qs.get("error", [None])[0]
|
|
275
|
+
|
|
276
|
+
if err:
|
|
277
|
+
user_token.set(
|
|
278
|
+
{"error": err, "error_description": qs.get("error_description", [None])[0]}
|
|
279
|
+
)
|
|
280
|
+
client.set_session_token(session, None)
|
|
281
|
+
await session.send_custom_message("csip_reset", {})
|
|
282
|
+
|
|
283
|
+
# 1) no code + no token -> redirect to CSI
|
|
284
|
+
if code is None and user_token() is None:
|
|
285
|
+
pk = auth.generate_pkce()
|
|
286
|
+
st = auth.pkce_state_encode(pk["verifier"])
|
|
287
|
+
params = {
|
|
288
|
+
"response_type": "code",
|
|
289
|
+
"client_id": os.environ.get("CSIAPPS_CLIENT_ID", ""),
|
|
290
|
+
"redirect_uri": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
|
|
291
|
+
"scope": os.environ.get("CSIAPPS_SCOPE", "read write"),
|
|
292
|
+
"code_challenge": pk["challenge"],
|
|
293
|
+
"code_challenge_method": pk["method"],
|
|
294
|
+
"state": st,
|
|
295
|
+
}
|
|
296
|
+
await session.send_custom_message(
|
|
297
|
+
"csip_redirect", config.auth_url() + "?" + urlencode(params)
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# 2) have code but no token yet -> exchange
|
|
302
|
+
if code is not None and user_token() is None:
|
|
303
|
+
verifier = auth.pkce_state_decode(state).get("v") if state else None
|
|
304
|
+
# exchange_code_for_token does a *blocking* httpx.post; run it
|
|
305
|
+
# off the event loop so a slow token endpoint can't stall every
|
|
306
|
+
# other session (a Python-only concern -- R has no shared loop).
|
|
307
|
+
tok = await asyncio.to_thread(
|
|
308
|
+
auth.exchange_code_for_token, code, verifier
|
|
309
|
+
)
|
|
310
|
+
user_token.set(tok)
|
|
311
|
+
|
|
312
|
+
# Shared consumer (production + sandbox): store the token per-session and
|
|
313
|
+
# load /me for the header.
|
|
314
|
+
@reactive.effect
|
|
315
|
+
@reactive.event(user_token)
|
|
316
|
+
async def _consume():
|
|
317
|
+
tok = user_token()
|
|
318
|
+
client.set_session_token(session, None)
|
|
319
|
+
|
|
320
|
+
if tok is None or tok.get("error"):
|
|
321
|
+
await session.send_custom_message("csip_reset", {})
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
access_token = tok.get("access_token")
|
|
325
|
+
if not access_token:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
client.set_session_token(session, access_token)
|
|
329
|
+
|
|
330
|
+
userinfo_url = config.userinfo_url()
|
|
331
|
+
if userinfo_url:
|
|
332
|
+
try:
|
|
333
|
+
# Blocking httpx.get -> run off the event loop so a slow /me
|
|
334
|
+
# endpoint can't stall other sessions (Python-only concern).
|
|
335
|
+
resp = await asyncio.to_thread(
|
|
336
|
+
httpx.get,
|
|
337
|
+
userinfo_url,
|
|
338
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
339
|
+
)
|
|
340
|
+
resp.raise_for_status()
|
|
341
|
+
userinfo.set(resp.json())
|
|
342
|
+
except Exception as e: # a stale/expired token degrades gracefully
|
|
343
|
+
ui.notification_show(f"Error loading user info: {e}", type="error")
|
|
344
|
+
|
|
345
|
+
@render.ui
|
|
346
|
+
def auth_status():
|
|
347
|
+
tok = user_token()
|
|
348
|
+
if tok is None:
|
|
349
|
+
return ui.tags.p("Redirecting to CSIAPPS for authentication...")
|
|
350
|
+
if tok.get("error"):
|
|
351
|
+
return ui.tags.p("Authentication error (see logs).")
|
|
352
|
+
if tok.get("unauthenticated"):
|
|
353
|
+
return ui.tags.p(
|
|
354
|
+
ui.HTML(
|
|
355
|
+
"Not authenticated — set CSIAPPS_ACCESS_TOKEN "
|
|
356
|
+
"to emulate login in sandbox mode."
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return ui.TagList(ui.tags.br(), ui.tags.p(_signed_in_text(userinfo(), sandbox)))
|
|
360
|
+
|
|
361
|
+
@reactive.effect
|
|
362
|
+
@reactive.event(input.logout, ignore_none=True)
|
|
363
|
+
async def _logout():
|
|
364
|
+
userinfo.set(None)
|
|
365
|
+
client.set_session_token(session, None)
|
|
366
|
+
if sandbox:
|
|
367
|
+
user_token.set(_seed_token_value())
|
|
368
|
+
else:
|
|
369
|
+
user_token.set(None)
|
|
370
|
+
await session.send_custom_message("csip_reset", {})
|
|
371
|
+
|
|
372
|
+
# Call the app's own server function so it keeps its lexical scope.
|
|
373
|
+
app_specific_logic(input, output, session)
|
|
374
|
+
|
|
375
|
+
return server
|
csiapps/auth.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""OAuth2 PKCE helpers, token exchange, and secret checks.
|
|
2
|
+
|
|
3
|
+
Ports the PKCE / token-exchange section of ``R/utils.R`` and ``check_secrets()``.
|
|
4
|
+
The R package leaned on ``httr2``/``openssl`` for these; in Python the PKCE
|
|
5
|
+
pieces are pure stdlib (``secrets`` + ``hashlib`` + ``base64``) and only the
|
|
6
|
+
token POST needs ``httpx``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .config import _message
|
|
20
|
+
|
|
21
|
+
# ---- PKCE base64url ----
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64url(raw: bytes) -> str:
|
|
25
|
+
"""base64url-encode bytes with padding stripped (matches R's pkce_base64url)."""
|
|
26
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _b64url_decode(s: str) -> bytes:
|
|
30
|
+
pad = -len(s) % 4
|
|
31
|
+
return base64.urlsafe_b64decode(s + "=" * pad)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_pkce() -> dict:
|
|
35
|
+
"""Generate a PKCE verifier/challenge pair.
|
|
36
|
+
|
|
37
|
+
Replaces ``httr2::oauth_flow_auth_code_pkce()``, which R's ``server_wrapper``
|
|
38
|
+
called inline. Returns ``{"verifier", "challenge", "method"}``.
|
|
39
|
+
"""
|
|
40
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
41
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
42
|
+
return {"verifier": verifier, "challenge": challenge, "method": "S256"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pkce_state_encode(verifier: str) -> str:
|
|
46
|
+
"""Encode a PKCE verifier into the opaque ``state`` string for the auth request."""
|
|
47
|
+
payload = json.dumps(
|
|
48
|
+
{"v": verifier, "r": secrets.randbelow(10**9) + 1},
|
|
49
|
+
separators=(",", ":"),
|
|
50
|
+
)
|
|
51
|
+
return _b64url(payload.encode("utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pkce_state_decode(state: str) -> dict:
|
|
55
|
+
"""Reverse :func:`pkce_state_encode`; returns the decoded ``{"v", "r"}`` dict."""
|
|
56
|
+
return json.loads(_b64url_decode(state).decode("utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- Token exchange ----
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def exchange_code_for_token(code: str, code_verifier: str | None = None) -> dict:
|
|
63
|
+
"""Exchange an authorization code for tokens at the CSIAPPS token endpoint.
|
|
64
|
+
|
|
65
|
+
HTTP error statuses do **not** raise (they are returned as an ``error`` dict,
|
|
66
|
+
mirroring the R ``req_error(is_error = FALSE)`` behaviour); transport-level
|
|
67
|
+
failures propagate as ``httpx`` exceptions, as they did in R.
|
|
68
|
+
"""
|
|
69
|
+
form = {
|
|
70
|
+
"grant_type": "authorization_code",
|
|
71
|
+
"code": code,
|
|
72
|
+
"redirect_uri": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
|
|
73
|
+
"code_verifier": code_verifier,
|
|
74
|
+
}
|
|
75
|
+
# Drop unset fields (httr2's req_body_form omits NULLs).
|
|
76
|
+
form = {k: v for k, v in form.items() if v is not None}
|
|
77
|
+
|
|
78
|
+
resp = httpx.post(
|
|
79
|
+
config.token_url(),
|
|
80
|
+
auth=(
|
|
81
|
+
os.environ.get("CSIAPPS_CLIENT_ID", ""),
|
|
82
|
+
os.environ.get("CSIAPPS_CLIENT_SECRET", ""),
|
|
83
|
+
),
|
|
84
|
+
data=form,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
body = resp.json()
|
|
89
|
+
except ValueError:
|
|
90
|
+
body = {"raw": resp.text}
|
|
91
|
+
|
|
92
|
+
if 200 <= resp.status_code < 300:
|
|
93
|
+
return body
|
|
94
|
+
return {
|
|
95
|
+
"error": "token_exchange_http_error",
|
|
96
|
+
"status": resp.status_code,
|
|
97
|
+
"payload": body,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- Secret / env checks ----
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_secrets(verbose: bool = False, sandbox: bool | None = None) -> bool:
|
|
105
|
+
"""Validate that the environment is configured for authentication.
|
|
106
|
+
|
|
107
|
+
Call this once at app startup to fail fast on a misconfigured deployment.
|
|
108
|
+
Behaviour depends on the mode:
|
|
109
|
+
|
|
110
|
+
- **Sandbox mode:** OAuth secret checks are skipped entirely (the sandbox
|
|
111
|
+
simulates login and needs no client credentials). Instead the presence or
|
|
112
|
+
absence of ``CSIAPPS_ACCESS_TOKEN`` is reported to stderr, and the function
|
|
113
|
+
never raises.
|
|
114
|
+
- **Production mode:** the OAuth URLs derived from the configured institute
|
|
115
|
+
plus ``CSIAPPS_REDIRECT_URI`` are checked for a well-formed ``http(s)``
|
|
116
|
+
scheme, and a :class:`ValueError` is raised listing any that are missing or
|
|
117
|
+
malformed.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
verbose: If ``True``, dump the resolved CSIAPPS environment (client id,
|
|
121
|
+
auth/token/userinfo URLs, redirect URI, scope, and whether a client
|
|
122
|
+
secret is set) to stderr. The client secret value itself is never
|
|
123
|
+
printed — only whether one is present. Defaults to ``False``.
|
|
124
|
+
sandbox: Force the mode for this check. ``True`` or ``False`` overrides
|
|
125
|
+
detection; ``None`` (the default) resolves via
|
|
126
|
+
[`is_sandbox_mode`][csiapps.config.is_sandbox_mode].
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: Always ``True`` when the environment is usable. In production the
|
|
130
|
+
function raises rather than returning ``False``, so a ``True`` return is
|
|
131
|
+
a positive assurance the required URLs are present.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: In production mode only, if any of ``CSIAPPS_AUTH_URL``,
|
|
135
|
+
``CSIAPPS_TOKEN_URL``, or ``CSIAPPS_REDIRECT_URI`` is missing or does
|
|
136
|
+
not start with ``http://`` or ``https://``.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
```python
|
|
140
|
+
import csiapps
|
|
141
|
+
|
|
142
|
+
# In sandbox mode: prints token status and returns True.
|
|
143
|
+
csiapps.check_secrets(verbose=True)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Note:
|
|
147
|
+
The auth and token URLs are derived from the institute set via
|
|
148
|
+
[`set_institute`][csiapps.config.set_institute], not read directly from
|
|
149
|
+
the environment, so set the institute before calling this.
|
|
150
|
+
"""
|
|
151
|
+
if sandbox is None:
|
|
152
|
+
sandbox = config.is_sandbox_mode()
|
|
153
|
+
|
|
154
|
+
if sandbox:
|
|
155
|
+
if os.environ.get("CSIAPPS_ACCESS_TOKEN", ""):
|
|
156
|
+
_message(
|
|
157
|
+
"csiapps sandbox: CSIAPPS_ACCESS_TOKEN found - real registration reads enabled"
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
_message(
|
|
161
|
+
"csiapps sandbox: no CSIAPPS_ACCESS_TOKEN set - running unauthenticated "
|
|
162
|
+
"(set a token to emulate login and load /me)"
|
|
163
|
+
)
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
url_re = re.compile(r"^https?://")
|
|
167
|
+
bad = []
|
|
168
|
+
if not url_re.match(config.auth_url()):
|
|
169
|
+
bad.append("CSIAPPS_AUTH_URL")
|
|
170
|
+
if not url_re.match(config.token_url()):
|
|
171
|
+
bad.append("CSIAPPS_TOKEN_URL")
|
|
172
|
+
if not url_re.match(os.environ.get("CSIAPPS_REDIRECT_URI", "")):
|
|
173
|
+
bad.append("CSIAPPS_REDIRECT_URI")
|
|
174
|
+
if bad:
|
|
175
|
+
raise ValueError("Invalid or missing URL env vars: " + ", ".join(bad))
|
|
176
|
+
|
|
177
|
+
if verbose:
|
|
178
|
+
_message(
|
|
179
|
+
f"AUTH_URL: '{config.auth_url()}' "
|
|
180
|
+
f"REDIRECT_URI: '{os.environ.get('CSIAPPS_REDIRECT_URI', '')}'"
|
|
181
|
+
)
|
|
182
|
+
env_dump = {
|
|
183
|
+
"CSIAPPS_CLIENT_ID": os.environ.get("CSIAPPS_CLIENT_ID", ""),
|
|
184
|
+
"CSIAPPS_CLIENT_SECRET_SET": bool(os.environ.get("CSIAPPS_CLIENT_SECRET", "")),
|
|
185
|
+
"CSIAPPS_AUTH_URL": config.auth_url(),
|
|
186
|
+
"CSIAPPS_TOKEN_URL": config.token_url(),
|
|
187
|
+
"CSIAPPS_REDIRECT_URI": os.environ.get("CSIAPPS_REDIRECT_URI", ""),
|
|
188
|
+
"CSIAPPS_SCOPE": os.environ.get("CSIAPPS_SCOPE", "read write"),
|
|
189
|
+
"CSIAPPS_USERINFO_URL": config.userinfo_url(),
|
|
190
|
+
}
|
|
191
|
+
_message("CSIAPPS environment on startup:")
|
|
192
|
+
_message(json.dumps(env_dump, indent=2))
|
|
193
|
+
|
|
194
|
+
return True
|