pycafe-server 0.0.7__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 (199) hide show
  1. pycafe_server/__about__.py +4 -0
  2. pycafe_server/__init__.py +3 -0
  3. pycafe_server/asgi.py +345 -0
  4. pycafe_server/auth.py +311 -0
  5. pycafe_server/config.py +172 -0
  6. pycafe_server/cookie.py +73 -0
  7. pycafe_server/database.py +174 -0
  8. pycafe_server/license.py +99 -0
  9. pycafe_server/session.py +56 -0
  10. pycafe_server/sign.py +14 -0
  11. pycafe_server/static/404.html +1 -0
  12. pycafe_server/static/_next/static/-dsYd_IR6KZ1xCK1JGQIe/_buildManifest.js +1 -0
  13. pycafe_server/static/_next/static/-dsYd_IR6KZ1xCK1JGQIe/_ssgManifest.js +1 -0
  14. pycafe_server/static/_next/static/chunks/117-2d310aa1d3fedd11.js +2 -0
  15. pycafe_server/static/_next/static/chunks/144-86024bdc2248d212.js +1 -0
  16. pycafe_server/static/_next/static/chunks/250-778b8977023babac.js +1 -0
  17. pycafe_server/static/_next/static/chunks/36-fbf60547c50ddd6d.js +1 -0
  18. pycafe_server/static/_next/static/chunks/484.3e91a8877443b77f.js +1 -0
  19. pycafe_server/static/_next/static/chunks/684-92effdd3a90cbd04.js +2 -0
  20. pycafe_server/static/_next/static/chunks/686-7260ec324cabf91a.js +1 -0
  21. pycafe_server/static/_next/static/chunks/70-70f8b9c8e8b73c0d.js +1 -0
  22. pycafe_server/static/_next/static/chunks/83-e3a2a619fdeb083b.js +1 -0
  23. pycafe_server/static/_next/static/chunks/92-69451410f0d6b437.js +1 -0
  24. pycafe_server/static/_next/static/chunks/921-9ecc230270e58d5f.js +1 -0
  25. pycafe_server/static/_next/static/chunks/aaea2bcf-debd16af2e1ee8d8.js +1 -0
  26. pycafe_server/static/_next/static/chunks/app/_not-found/page-320d5b624e913067.js +1 -0
  27. pycafe_server/static/_next/static/chunks/app/admin/page-ad0c1dd3957e012a.js +1 -0
  28. pycafe_server/static/_next/static/chunks/app/layout-182dee2a35e31a08.js +1 -0
  29. pycafe_server/static/_next/static/chunks/app/page-a5c474768bb5f2e2.js +1 -0
  30. pycafe_server/static/_next/static/chunks/app/sign-in/page-4f90785e5fc83287.js +1 -0
  31. pycafe_server/static/_next/static/chunks/app/snippet/[type]/[version]/page-fb30ffc54630e8fd.js +1 -0
  32. pycafe_server/static/_next/static/chunks/app/view/page-e53355c1ed692ec4.js +1 -0
  33. pycafe_server/static/_next/static/chunks/bc0697a0-9b4145a23a803cb0.js +3 -0
  34. pycafe_server/static/_next/static/chunks/d8465030-fe21b9c8223e3143.js +1 -0
  35. pycafe_server/static/_next/static/chunks/fd9d1056-ad5bbc8af4b8126c.js +1 -0
  36. pycafe_server/static/_next/static/chunks/framework-00a8ba1a63cfdc9e.js +1 -0
  37. pycafe_server/static/_next/static/chunks/main-830d03220a87b2cf.js +1 -0
  38. pycafe_server/static/_next/static/chunks/main-app-fec1dc80d88c979a.js +1 -0
  39. pycafe_server/static/_next/static/chunks/pages/_app-15e2daefa259f0b5.js +1 -0
  40. pycafe_server/static/_next/static/chunks/pages/_error-28b803cb2479b966.js +1 -0
  41. pycafe_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  42. pycafe_server/static/_next/static/chunks/webpack-a25853d04f8b4fca.js +1 -0
  43. pycafe_server/static/_next/static/css/00810e171700dd8c.css +1 -0
  44. pycafe_server/static/_next/static/css/107ede932147228b.css +1 -0
  45. pycafe_server/static/_next/static/css/2c73632f60809884.css +1 -0
  46. pycafe_server/static/_next/static/css/70765a0735f6401f.css +1 -0
  47. pycafe_server/static/_next/static/css/ac106bbd0da9ef31.css +1 -0
  48. pycafe_server/static/_next/static/css/c35ecba93103ef73.css +1 -0
  49. pycafe_server/static/_next/static/media/26a46d62cd723877-s.woff2 +0 -0
  50. pycafe_server/static/_next/static/media/55c55f0601d81cf3-s.woff2 +0 -0
  51. pycafe_server/static/_next/static/media/581909926a08bbc8-s.woff2 +0 -0
  52. pycafe_server/static/_next/static/media/6d93bde91c0c2823-s.woff2 +0 -0
  53. pycafe_server/static/_next/static/media/97e0cb1ae144a2a9-s.woff2 +0 -0
  54. pycafe_server/static/_next/static/media/a34f9d1faa5f3315-s.p.woff2 +0 -0
  55. pycafe_server/static/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
  56. pycafe_server/static/_next/static/media/material-icons-outlined.78a93b20.woff +0 -0
  57. pycafe_server/static/_next/static/media/material-icons-outlined.f86cb7b0.woff2 +0 -0
  58. pycafe_server/static/_next/static/media/material-icons-round.92dc7ca2.woff +0 -0
  59. pycafe_server/static/_next/static/media/material-icons-round.b10ec9db.woff2 +0 -0
  60. pycafe_server/static/_next/static/media/material-icons-sharp.3885863e.woff2 +0 -0
  61. pycafe_server/static/_next/static/media/material-icons-sharp.a71cb2bf.woff +0 -0
  62. pycafe_server/static/_next/static/media/material-icons-two-tone.588d6313.woff +0 -0
  63. pycafe_server/static/_next/static/media/material-icons-two-tone.675bd578.woff2 +0 -0
  64. pycafe_server/static/_next/static/media/material-icons.4ad034d2.woff +0 -0
  65. pycafe_server/static/_next/static/media/material-icons.59322316.woff2 +0 -0
  66. pycafe_server/static/admin.html +1 -0
  67. pycafe_server/static/admin.txt +9 -0
  68. pycafe_server/static/app.py +102 -0
  69. pycafe_server/static/backgrounds/image16.svg +9 -0
  70. pycafe_server/static/backgrounds/image17.svg +9 -0
  71. pycafe_server/static/backgrounds/image18.svg +9 -0
  72. pycafe_server/static/badge.svg +1 -0
  73. pycafe_server/static/bootstrap_jupyter_kernel.py +52 -0
  74. pycafe_server/static/coffeecup.gif +0 -0
  75. pycafe_server/static/dash_daq-0.5.0-py3-none-any.whl +0 -0
  76. pycafe_server/static/docs/apps/dash-app.webp +0 -0
  77. pycafe_server/static/docs/apps/dash-open.webp +0 -0
  78. pycafe_server/static/docs/apps/dash-preview.webp +0 -0
  79. pycafe_server/static/docs/apps/dash-share-app.webp +0 -0
  80. pycafe_server/static/docs/apps/dash-share-embed.webp +0 -0
  81. pycafe_server/static/docs/apps/dash-share.webp +0 -0
  82. pycafe_server/static/docs/apps/dash-upload.webp +0 -0
  83. pycafe_server/static/docs/apps/panel-app.webp +0 -0
  84. pycafe_server/static/docs/apps/panel-open.webp +0 -0
  85. pycafe_server/static/docs/apps/panel-preview.webp +0 -0
  86. pycafe_server/static/docs/apps/panel-share-app.webp +0 -0
  87. pycafe_server/static/docs/apps/panel-share-embed.webp +0 -0
  88. pycafe_server/static/docs/apps/panel-share.webp +0 -0
  89. pycafe_server/static/docs/apps/panel-upload.webp +0 -0
  90. pycafe_server/static/docs/apps/shiny-app.webp +0 -0
  91. pycafe_server/static/docs/apps/shiny-open.webp +0 -0
  92. pycafe_server/static/docs/apps/shiny-preview.webp +0 -0
  93. pycafe_server/static/docs/apps/shiny-share-app.webp +0 -0
  94. pycafe_server/static/docs/apps/shiny-share-embed.webp +0 -0
  95. pycafe_server/static/docs/apps/shiny-share.webp +0 -0
  96. pycafe_server/static/docs/apps/shiny-upload.webp +0 -0
  97. pycafe_server/static/docs/apps/solara-app.webp +0 -0
  98. pycafe_server/static/docs/apps/solara-open.webp +0 -0
  99. pycafe_server/static/docs/apps/solara-preview.webp +0 -0
  100. pycafe_server/static/docs/apps/solara-share-app.webp +0 -0
  101. pycafe_server/static/docs/apps/solara-share-embed.webp +0 -0
  102. pycafe_server/static/docs/apps/solara-share.webp +0 -0
  103. pycafe_server/static/docs/apps/solara-upload.webp +0 -0
  104. pycafe_server/static/docs/apps/streamlit-app.webp +0 -0
  105. pycafe_server/static/docs/apps/streamlit-open.webp +0 -0
  106. pycafe_server/static/docs/apps/streamlit-preview.webp +0 -0
  107. pycafe_server/static/docs/apps/streamlit-share-app.webp +0 -0
  108. pycafe_server/static/docs/apps/streamlit-share-embed.webp +0 -0
  109. pycafe_server/static/docs/apps/streamlit-share.webp +0 -0
  110. pycafe_server/static/docs/apps/streamlit-upload.webp +0 -0
  111. pycafe_server/static/docs/apps/vizro-app.webp +0 -0
  112. pycafe_server/static/docs/apps/vizro-open.webp +0 -0
  113. pycafe_server/static/docs/apps/vizro-preview.webp +0 -0
  114. pycafe_server/static/docs/apps/vizro-share-app.webp +0 -0
  115. pycafe_server/static/docs/apps/vizro-share-embed.webp +0 -0
  116. pycafe_server/static/docs/apps/vizro-share.webp +0 -0
  117. pycafe_server/static/docs/apps/vizro-upload.webp +0 -0
  118. pycafe_server/static/docs/ipyreact-status.webp +0 -0
  119. pycafe_server/static/docs/secrets/secret-enter-value.webp +0 -0
  120. pycafe_server/static/docs/secrets/secret-manager.webp +0 -0
  121. pycafe_server/static/docs/secrets/secret-printed.webp +0 -0
  122. pycafe_server/static/docs/secrets/secret-prompt-with-reason.webp +0 -0
  123. pycafe_server/static/docs/secrets/secret-prompt.webp +0 -0
  124. pycafe_server/static/docs/secrets/secret-with-reason-enter-value.webp +0 -0
  125. pycafe_server/static/docs/secrets/secret-with-reason-printed.webp +0 -0
  126. pycafe_server/static/docs/vizro-ci.webp +0 -0
  127. pycafe_server/static/favicon.ico +0 -0
  128. pycafe_server/static/fs-worker.js +2 -0
  129. pycafe_server/static/fs-worker.js.LICENSE.txt +12 -0
  130. pycafe_server/static/google_crc32c-1.5.0-py3-none-any.whl +0 -0
  131. pycafe_server/static/icons/discord.svg +1 -0
  132. pycafe_server/static/icons/folder.svg +1 -0
  133. pycafe_server/static/icons/terminal.svg +1 -0
  134. pycafe_server/static/index.html +1 -0
  135. pycafe_server/static/index.txt +9 -0
  136. pycafe_server/static/ipykernel-6.9.2-py3-none-any.whl +0 -0
  137. pycafe_server/static/jupyterlite_pyodide_kernel-0.0.8-py3-none-any.whl +0 -0
  138. pycafe_server/static/logo.png +0 -0
  139. pycafe_server/static/logos/gradio_logo_full.svg +20 -0
  140. pycafe_server/static/logos/gradio_logo_full_dark.svg +23 -0
  141. pycafe_server/static/logos/panel_logo.png +0 -0
  142. pycafe_server/static/logos/panel_logo_full.png +0 -0
  143. pycafe_server/static/logos/panel_logo_full_dark.png +0 -0
  144. pycafe_server/static/logos/plotly_logo.png +0 -0
  145. pycafe_server/static/logos/plotly_logo_full.png +0 -0
  146. pycafe_server/static/logos/plotly_logo_full_white.png +0 -0
  147. pycafe_server/static/logos/plotly_logo_white.png +0 -0
  148. pycafe_server/static/logos/pycafe_logo.png +0 -0
  149. pycafe_server/static/logos/python.svg +265 -0
  150. pycafe_server/static/logos/shiny_logo.png +0 -0
  151. pycafe_server/static/logos/shiny_logo_full.svg +80 -0
  152. pycafe_server/static/logos/shiny_logo_full_white.svg +80 -0
  153. pycafe_server/static/logos/solara_logo.svg +8 -0
  154. pycafe_server/static/logos/streamlit_logo.png +0 -0
  155. pycafe_server/static/logos/streamlit_logo_full.svg +6 -0
  156. pycafe_server/static/logos/streamlit_logo_full_white.svg +6 -0
  157. pycafe_server/static/logos/vizro_logo.svg +3 -0
  158. pycafe_server/static/logos/vizro_logo_full.svg +9 -0
  159. pycafe_server/static/logos/vizro_logo_full_white.svg +9 -0
  160. pycafe_server/static/next.svg +1 -0
  161. pycafe_server/static/pyodide_kernel-0.0.8-py3-none-any.whl +0 -0
  162. pycafe_server/static/pyperclip-1.8.2-py3-none-any.whl +0 -0
  163. pycafe_server/static/qr_buy_eu.png +0 -0
  164. pycafe_server/static/qr_buy_us.png +0 -0
  165. pycafe_server/static/requirements.txt +16 -0
  166. pycafe_server/static/service-worker.js +1 -0
  167. pycafe_server/static/sign-in.html +1 -0
  168. pycafe_server/static/sign-in.txt +9 -0
  169. pycafe_server/static/snippet/dash/v1.html +1 -0
  170. pycafe_server/static/snippet/dash/v1.txt +10 -0
  171. pycafe_server/static/snippet/panel/v1.html +1 -0
  172. pycafe_server/static/snippet/panel/v1.txt +10 -0
  173. pycafe_server/static/snippet/shiny/v1.html +1 -0
  174. pycafe_server/static/snippet/shiny/v1.txt +10 -0
  175. pycafe_server/static/snippet/solara/v1.html +1 -0
  176. pycafe_server/static/snippet/solara/v1.txt +10 -0
  177. pycafe_server/static/snippet/streamlit/v1.html +1 -0
  178. pycafe_server/static/snippet/streamlit/v1.txt +10 -0
  179. pycafe_server/static/snippet/vizro/v1.html +1 -0
  180. pycafe_server/static/snippet/vizro/v1.txt +10 -0
  181. pycafe_server/static/test_dash.py +59 -0
  182. pycafe_server/static/test_ipykernel.py +40 -0
  183. pycafe_server/static/test_jupyterlab.py +51 -0
  184. pycafe_server/static/test_solara.py +77 -0
  185. pycafe_server/static/test_starlette.py +65 -0
  186. pycafe_server/static/test_streamlit.py +31 -0
  187. pycafe_server/static/test_tornado.py +94 -0
  188. pycafe_server/static/tor.py +18 -0
  189. pycafe_server/static/tornado-6.4.2-py3-none-any.whl +0 -0
  190. pycafe_server/static/vercel.svg +1 -0
  191. pycafe_server/static/view.html +1 -0
  192. pycafe_server/static/view.txt +9 -0
  193. pycafe_server/static/webworker.js +2 -0
  194. pycafe_server/static/webworker.js.LICENSE.txt +5 -0
  195. pycafe_server/uv_util.py +114 -0
  196. pycafe_server-0.0.7.dist-info/METADATA +60 -0
  197. pycafe_server-0.0.7.dist-info/RECORD +199 -0
  198. pycafe_server-0.0.7.dist-info/WHEEL +4 -0
  199. pycafe_server-0.0.7.dist-info/licenses/LICENSE.txt +9 -0
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2024-present Maarten A. Breddels <maartenbreddels@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.0.7"
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2024-present Maarten A. Breddels <maartenbreddels@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
pycafe_server/asgi.py ADDED
@@ -0,0 +1,345 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from pathlib import Path
5
+ import sys
6
+ import typing
7
+ import subprocess
8
+
9
+ from starlette.applications import Starlette
10
+ from starlette.staticfiles import StaticFiles
11
+ from starlette.responses import PlainTextResponse, JSONResponse, Response
12
+ from starlette.routing import Route
13
+ from starlette.requests import Request
14
+ from starlette.types import Receive, Scope, Send
15
+ from starlette.responses import RedirectResponse
16
+ from starlette.applications import Starlette
17
+ from starlette.middleware import Middleware
18
+ from .session import SessionMiddleware
19
+ from starlette.middleware.cors import CORSMiddleware
20
+ from starlette.middleware.authentication import AuthenticationMiddleware
21
+ from starlette.middleware import Middleware
22
+ from .uv_util import _run_resolve
23
+ from . import database
24
+
25
+ import uvicorn
26
+
27
+ from . import config
28
+ from . import auth
29
+ from . import license
30
+ from . import sign
31
+
32
+ HERE = Path(__file__).parent
33
+
34
+ logger = logging.getLogger(__name__)
35
+ trialmode = False
36
+ from .cookie import Cookie
37
+
38
+
39
+ session_cookie = Cookie(
40
+ "pycafe_session",
41
+ same_site="none",
42
+ secure=True,
43
+ secret_key=config.PYCAFE_SESSION_SECRET_KEY,
44
+ )
45
+ middleware = []
46
+ if os.environ.get("ENV", "dev") == "dev":
47
+ middleware = [
48
+ Middleware(
49
+ CORSMiddleware,
50
+ allow_origins=["localhost"],
51
+ ),
52
+ ]
53
+ middleware += [
54
+ Middleware(
55
+ SessionMiddleware,
56
+ cookie=session_cookie,
57
+ ),
58
+ Middleware(AuthenticationMiddleware, backend=auth.AuthBackend()),
59
+ ]
60
+ app = Starlette(middleware=middleware)
61
+
62
+
63
+ static_dir = (HERE / "static").resolve()
64
+ print("static assets in", static_dir)
65
+
66
+
67
+ class StaticFilesNextJs(StaticFiles):
68
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
69
+ request = Request(scope, receive=receive)
70
+ if (
71
+ config.auth_is_configured()
72
+ and config.PYCAFE_SERVER_PRE_AUTH
73
+ and not request.user.is_authenticated
74
+ ):
75
+ # redirect to /_login
76
+ # TODO: url encode etc
77
+ redirect_uri = str(request.url)
78
+ url = f"/_login?next_url={redirect_uri}"
79
+ await RedirectResponse(url)(scope, receive, send)
80
+ else:
81
+ await super().__call__(scope, receive, send)
82
+
83
+ def lookup_path(
84
+ self, path: str
85
+ ) -> typing.Tuple[str, typing.Optional[os.stat_result]]:
86
+ attempt1 = super().lookup_path(path)
87
+ if attempt1[1] is not None:
88
+ return attempt1
89
+ # a path like /snippet/solara/v1 should be resolved to /snippet/solara/v1.html
90
+ attempt2 = super().lookup_path(path + ".html")
91
+ return attempt2
92
+
93
+ def file_response(self, *args, **kwargs) -> Response:
94
+ response = super().file_response(*args, **kwargs)
95
+ # we don't want to cache html pages, since if we logout, it
96
+ # should always fetch from the server, instead of using the cache
97
+ response.headers["cache-control"] = "no-cache"
98
+ return response
99
+
100
+
101
+ async def resolve_uv(request: Request):
102
+ body = await request.body()
103
+ args = json.loads(body)
104
+ requirements = args["requirements"]
105
+ constraints = args["constraints"]
106
+ python_version = args["python_version"]
107
+ overrides = args["overrides"]
108
+ universal = args["universal"] == "true"
109
+
110
+ try:
111
+ result = _run_resolve(
112
+ requirements, constraints, overrides, python_version, universal=universal
113
+ )
114
+ except subprocess.CalledProcessError as e:
115
+ print("Failed to resolve", e.stderr)
116
+ return e.stderr, 500
117
+ return PlainTextResponse(result, status_code=200)
118
+
119
+
120
+ def get_user_id(userinfo):
121
+ id_field = config.get("PYCAFE_SERVER_USER_ID_FIELD", default="email")
122
+ if id_field not in userinfo:
123
+ logger.error(
124
+ f"no value found in userinfo with key PYCAFE_SERVER_USER_ID_FIELD={id_field}, possible fields are {list(userinfo.keys())}"
125
+ )
126
+ return JSONResponse(
127
+ {
128
+ "error": "Could not find a field in the auth data to uniquely describe the user, see server logs and configuration"
129
+ },
130
+ status_code=500,
131
+ )
132
+ return userinfo[id_field]
133
+
134
+
135
+ async def logins(request: Request):
136
+ # only admins can see logins
137
+
138
+ if not trialmode:
139
+ userinfo = auth.get_userinfo(request)
140
+ if userinfo is None:
141
+ return JSONResponse({"error": "Not authenticated"}, status_code=401)
142
+
143
+ user_id = get_user_id(userinfo)
144
+ is_admin = user_is_admin(user_id)
145
+ if not is_admin:
146
+ return JSONResponse({"error": "Not authorized"}, status_code=403)
147
+
148
+ try:
149
+ logins_db = database.get_logins()
150
+ logins = [
151
+ {
152
+ "user_id": login.user_id,
153
+ "datetime": login.datetime.isoformat(),
154
+ "email": login.email,
155
+ "is_editor": login.is_editor,
156
+ "is_admin": login.is_admin,
157
+ # "userinfo": login.userinfo,
158
+ }
159
+ for login in logins_db
160
+ ]
161
+
162
+ return JSONResponse({"logins": logins})
163
+ except Exception:
164
+ logger.exception("Failed to get logins")
165
+ return JSONResponse({"error": "Failed to get logins"}, status_code=500)
166
+
167
+
168
+ async def info(request: Request):
169
+ userinfo = auth.get_userinfo(request)
170
+ # we should reply with the following type from types.ts
171
+ """
172
+ export type UserInfo = {
173
+ user_id: string;
174
+ full_name: string;
175
+ username: string;
176
+ email: string;
177
+ avatar: string;
178
+ is_editor: boolean;
179
+ is_admin: boolean;
180
+ meta: { maxFileSize: number } | null;
181
+ };
182
+ """
183
+
184
+ settings = config.get_settings()
185
+ settings["trialmode"] = trialmode
186
+ if trialmode:
187
+ settings["message"] = (
188
+ "Auth is not configured, please set up auth, running in trial mode"
189
+ )
190
+
191
+ if userinfo is None:
192
+ # if we do not have auth, we will always show the instance_id
193
+ if not config.auth_is_configured():
194
+ settings["instanceId"] = database.instance_id
195
+ return JSONResponse({"user": None, "settings": settings})
196
+ else:
197
+ user_id = get_user_id(userinfo)
198
+ is_editor = user_is_editor(user_id)
199
+ is_admin = user_is_admin(user_id)
200
+
201
+ if is_admin:
202
+ settings["instanceId"] = database.instance_id
203
+ try:
204
+ email = userinfo.get("email", "")
205
+ database.log_login(user_id, email, is_editor, is_admin, userinfo)
206
+ except Exception:
207
+ logger.exception(f"Failed to log login")
208
+
209
+ return JSONResponse(
210
+ {
211
+ "user": {
212
+ "user_id": user_id,
213
+ "full_name": userinfo.get("name", ""),
214
+ "username": userinfo.get("preferred_username", ""),
215
+ "email": userinfo.get("email", ""),
216
+ "avatar": userinfo.get("picture", ""),
217
+ "is_editor": is_editor,
218
+ "is_admin": is_admin,
219
+ "meta": None,
220
+ },
221
+ "settings": settings,
222
+ }
223
+ )
224
+
225
+
226
+ def user_is_editor(user_id: str) -> bool:
227
+ if trialmode and not config.PYCAFE_SERVER_EDITORS:
228
+ logger.debug(
229
+ f"user {user_id} is an editor, because we are in trialmode and PYCAFE_SERVER_EDITOR is not set"
230
+ )
231
+ return True
232
+ is_editor = user_id in config.PYCAFE_SERVER_EDITORS
233
+ if is_editor:
234
+ logger.debug(
235
+ f"user {user_id} is an editor, because they are in PYCAFE_SERVER_EDITORS"
236
+ )
237
+ else:
238
+ logger.debug(
239
+ f"user {user_id} is not an editor, because they are not in PYCAFE_SERVER_EDITORS"
240
+ )
241
+ return is_editor
242
+
243
+
244
+ def user_is_admin(user_id: str) -> bool:
245
+ is_admin = user_id in config.PYCAFE_SERVER_ADMINS
246
+ if is_admin:
247
+ logger.debug(
248
+ f"user {user_id} is an admin, because they are in PYCAFE_SERVER_ADMINS"
249
+ )
250
+ else:
251
+ logger.debug(
252
+ f"user {user_id} is not an admin, because they are not in PYCAFE_SERVER_ADMINS"
253
+ )
254
+ return is_admin
255
+
256
+
257
+ async def sign_hash(request: Request):
258
+ # ensure login?
259
+ # we only support ?hash=<hash> for now
260
+
261
+ userinfo = auth.get_userinfo(request)
262
+ user_id = get_user_id(userinfo) if userinfo is not None else None
263
+ if not trialmode:
264
+ if userinfo is None:
265
+ return JSONResponse(
266
+ {"error": "Not authenticated, please login"}, status_code=401
267
+ )
268
+ user_id = get_user_id(userinfo)
269
+ if not user_is_editor(user_id):
270
+ return JSONResponse(
271
+ {"error": "Not authorized, not an editor"}, status_code=403
272
+ )
273
+ else:
274
+ if user_id is None:
275
+ user_id = "trailmode" # just make it work in trial mode
276
+ hash = request.query_params.get("hash")
277
+ if hash is None:
278
+ return JSONResponse({"error": "No hash provided"}, status_code=500)
279
+ signature_jwt = sign.sign_hash(hash, user_id)
280
+ return JSONResponse({"signatureJwt": signature_jwt})
281
+
282
+
283
+ app.add_route("/api/resolve", resolve_uv, methods=["POST"])
284
+ app.add_route("/api/info", info)
285
+ app.add_route("/api/logins", logins)
286
+ app.add_route("/api/sign", sign_hash)
287
+ # starlette does not seem to merge multiple routes at the common path
288
+ # so we manually add all routes from the auth app
289
+ for route in auth.app.routes:
290
+ app.add_route(route.path, route.endpoint)
291
+ app.mount("/", StaticFilesNextJs(directory=static_dir, html=True), name="static")
292
+
293
+
294
+ if not config.PYCAFE_SERVER_INSECURE_MODE_DONT_USE_IN_PRODUCTION:
295
+ if not config.auth_is_configured() and not config.auth_using_proxy():
296
+ print(
297
+ "ERROR: Insecure mode is not enabled, and auth is not configured. Please set up auth.",
298
+ file=sys.stderr,
299
+ )
300
+ sys.exit(1)
301
+ if config.PYCAFE_SERVER_ENABLE_EXPORT and not config.signing_is_configured():
302
+ print(
303
+ "ERROR: Insecure mode is not enabled, and signing is not configured. Please set up signing.",
304
+ file=sys.stderr,
305
+ )
306
+ sys.exit(1)
307
+ else:
308
+ print(
309
+ "WARNING: Insecure mode is enabled. Do not use in production.", file=sys.stderr
310
+ )
311
+
312
+ if license.license:
313
+ if not config.auth_is_configured():
314
+ print(
315
+ "ERROR: Valid license found, but not auth is setup. Please set up auth. Entering trial mode",
316
+ file=sys.stderr,
317
+ )
318
+ trialmode = True
319
+ if license.license.sub != database.instance_id:
320
+ print(
321
+ f"ERROR: License is for {license.license.sub}, but this instance has id {database.instance_id}. Please obtain a new license.",
322
+ file=sys.stderr,
323
+ )
324
+ if config.PYCAFE_SERVER_INSECURE_MODE_DONT_USE_IN_PRODUCTION:
325
+ trialmode = True
326
+ print("Entering trial mode.")
327
+ else:
328
+ sys.exit(1)
329
+ if len(config.PYCAFE_SERVER_EDITORS) > license.license.max_editors:
330
+ print(
331
+ f"ERROR: License does not allow {len(config.PYCAFE_SERVER_EDITORS)} editors, only {license.license.max_editors}.",
332
+ file=sys.stderr,
333
+ )
334
+ if config.PYCAFE_SERVER_INSECURE_MODE_DONT_USE_IN_PRODUCTION:
335
+ trialmode = True
336
+ print("Entering trial mode.")
337
+ else:
338
+ sys.exit(1)
339
+ else:
340
+ print("No license found, entering trial mode.", file=sys.stderr)
341
+ trialmode = True
342
+
343
+
344
+ if __name__ == "__main__":
345
+ uvicorn.run(app, host="127.0.0.1", port=8001)
pycafe_server/auth.py ADDED
@@ -0,0 +1,311 @@
1
+ from functools import cache
2
+ import json
3
+ import logging
4
+ import sys
5
+ import jwt
6
+ import typing
7
+ import requests
8
+ from starlette.authentication import (
9
+ AuthCredentials,
10
+ AuthenticationBackend,
11
+ AuthenticationError,
12
+ SimpleUser,
13
+ )
14
+ from starlette.routing import Router
15
+ from starlette.requests import HTTPConnection
16
+ from starlette.types import ASGIApp, Receive, Scope, Send
17
+ from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
18
+ from starlette.datastructures import MutableHeaders
19
+
20
+
21
+ from starlette.responses import (
22
+ PlainTextResponse,
23
+ Response,
24
+ RedirectResponse,
25
+ JSONResponse,
26
+ )
27
+ from starlette.authentication import UnauthenticatedUser
28
+ from starlette.requests import Request
29
+ from cryptography.hazmat.primitives.asymmetric import rsa
30
+ from cryptography.hazmat.primitives import serialization
31
+ from cryptography.hazmat.backends import default_backend
32
+ import base64
33
+
34
+ from .cookie import Cookie
35
+ from . import config
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ access_token_cookie = Cookie(
40
+ "pycafe_access_token",
41
+ same_site="none",
42
+ secure=True,
43
+ secret_key=config.PYCAFE_SESSION_SECRET_KEY,
44
+ )
45
+ id_token_cookie = Cookie(
46
+ "pycafe_id_token",
47
+ same_site="none",
48
+ secure=True,
49
+ secret_key=config.PYCAFE_SESSION_SECRET_KEY,
50
+ )
51
+ refresh_token_cookie = Cookie(
52
+ "pycafe_refresh_token",
53
+ same_site="none",
54
+ secure=True,
55
+ secret_key=config.PYCAFE_SESSION_SECRET_KEY,
56
+ )
57
+ userinfo_cookie = Cookie(
58
+ "pycafe_userinfo",
59
+ same_site="none",
60
+ secure=True,
61
+ secret_key=config.PYCAFE_SESSION_SECRET_KEY,
62
+ )
63
+
64
+
65
+ def load_rsa_key(n, e):
66
+ """Construct an RSA public key from modulus and exponent in JWKS format."""
67
+ modulus = int.from_bytes(base64.urlsafe_b64decode(n + "=="), "big")
68
+ exponent = int.from_bytes(base64.urlsafe_b64decode(e + "=="), "big")
69
+ public_key = rsa.RSAPublicNumbers(exponent, modulus).public_key(default_backend())
70
+ return public_key
71
+
72
+
73
+ @cache
74
+ def get_aws_alb_public_key(kid: str, region: str) -> str:
75
+ url = "https://public-keys.auth.elb." + region + ".amazonaws.com/" + kid
76
+ req = requests.get(url)
77
+ pub_key = req.text
78
+ return pub_key
79
+
80
+
81
+ def decode_jwt_on_alb(encoded_jwt: str, region=config.PYCAFE_SERVER_AWS_REGION):
82
+ # Step 1: Validate the signer
83
+ expected_alb_arn = "arn:aws:elasticloadbalancing:region-code:account-id:loadbalancer/app/load-balancer-name/load-balancer-id"
84
+
85
+ jwt_headers = encoded_jwt.split(".")[0]
86
+ decoded_jwt_headers_bytes = base64.b64decode(jwt_headers + "==")
87
+ decoded_jwt_headers = decoded_jwt_headers_bytes.decode("utf-8")
88
+ decoded_json = json.loads(decoded_jwt_headers)
89
+ received_alb_arn = decoded_json["signer"]
90
+
91
+ # TODO: we need to verify the signer
92
+ # assert expected_alb_arn == received_alb_arn, "Invalid Signer"
93
+
94
+ # Step 2: Get the key id from JWT headers (the kid field)
95
+ kid = decoded_json["kid"]
96
+
97
+ # Step 3: Get the public key from regional endpoint
98
+ pub_key = get_aws_alb_public_key(kid, region)
99
+
100
+ # Step 4: Get the payload
101
+ # TODO: we want to know if aud and iss are set in the jwt
102
+ return jwt.decode(
103
+ encoded_jwt,
104
+ pub_key,
105
+ algorithms=["ES256", "RS256"],
106
+ options={"verify_aud": False, "verify_iss": False},
107
+ )
108
+
109
+
110
+ def decode_jwt(token):
111
+ rsa_key = {}
112
+ headers = jwt.get_unverified_header(token)
113
+ if config.jwks_client is None:
114
+ raise ValueError(
115
+ "No JWKS client available to verify the token, please set PYCAFE_SERVER_JWKS_URL"
116
+ )
117
+ for key in config.jwks_client["keys"]:
118
+ if key["kid"] == headers["kid"]:
119
+ rsa_key = load_rsa_key(key["n"], key["e"])
120
+ break
121
+
122
+ if not rsa_key:
123
+ raise ValueError("Public key not found for the provided token.")
124
+
125
+ # Decode the JWT and verify its claims
126
+ decoded = jwt.decode(
127
+ token,
128
+ rsa_key,
129
+ algorithms=["RS256"],
130
+ audience=config.PYCAFE_SERVER_CLIENT_ID,
131
+ )
132
+ return decoded
133
+
134
+
135
+ def get_userinfo(request: HTTPConnection) -> dict | None:
136
+ if config.auth_using_proxy():
137
+ headers = request.headers
138
+
139
+ identity: str | None = None
140
+ email: str | None = None
141
+ userinfo: dict | None = None
142
+
143
+ if config.PYCAFE_SERVER_PROXY_AUTH_HEADER_IDENTITY:
144
+ identity = headers.get(config.PYCAFE_SERVER_PROXY_AUTH_HEADER_IDENTITY)
145
+ if identity is None:
146
+ print(
147
+ f"No header {config.PYCAFE_SERVER_PROXY_AUTH_HEADER_IDENTITY} found, possible headers are {list(headers.keys())}",
148
+ file=sys.stderr,
149
+ )
150
+
151
+ if config.PYCAFE_SERVER_PROXY_AUTH_HEADER_EMAIL:
152
+ email = headers.get(config.PYCAFE_SERVER_PROXY_AUTH_HEADER_EMAIL)
153
+ if email is None:
154
+ print(
155
+ f"No header {config.PYCAFE_SERVER_PROXY_AUTH_HEADER_EMAIL} found, possible headers are {list(headers.keys())}",
156
+ file=sys.stderr,
157
+ )
158
+
159
+ if config.PYCAFE_SERVER_PROXY_AUTH_HEADER_USER_JWT:
160
+ jwt_data = headers.get(config.PYCAFE_SERVER_PROXY_AUTH_HEADER_USER_JWT)
161
+ if jwt_data is None:
162
+ print(
163
+ f"No header {config.PYCAFE_SERVER_PROXY_AUTH_HEADER_USER_JWT} found, possible headers are {list(headers.keys())}",
164
+ file=sys.stderr,
165
+ )
166
+ if jwt_data:
167
+ if jwt_data.startswith("Bearer "):
168
+ jwt_data = jwt_data[7:].strip()
169
+ userinfo = decode_jwt(jwt_data)
170
+
171
+ if userinfo is None:
172
+ if identity is None:
173
+ raise ValueError(
174
+ "No identity found in headers or PYCAFE_SERVER_PROXY_AUTH_HEADER_IDENTITY not configured, and not userinfo found or PYCAFE_SERVER_PROXY_AUTH_HEADER_USER_JWT not configured"
175
+ )
176
+ else:
177
+ userinfo = {
178
+ "user_id": identity,
179
+ "email": email,
180
+ }
181
+ else:
182
+ userinfo = userinfo_cookie.get_dict(request)
183
+ if userinfo is None:
184
+ return None
185
+ else:
186
+ client_id = request.session.get("client_id")
187
+ # if we changed the oauth provider, we should not use the old user
188
+ if client_id is not None and client_id != config.PYCAFE_SERVER_CLIENT_ID:
189
+ logger.error(
190
+ "OIDC error, client id mismatch (%s != %s), did you change the oauth provider? We are assuming you are not logged in now",
191
+ client_id,
192
+ config.PYCAFE_SERVER_CLIENT_ID,
193
+ )
194
+ userinfo = None
195
+ assert userinfo is not None
196
+ aud = userinfo.get("aud")
197
+ if aud is not None and aud != config.PYCAFE_SERVER_CLIENT_ID:
198
+ logger.error(
199
+ "OIDC error, audience mismatch (%s != %s), did you change the oauth provider? We are assuming you are not logged in now",
200
+ aud,
201
+ config.PYCAFE_SERVER_CLIENT_ID,
202
+ )
203
+ userinfo = None
204
+
205
+ return userinfo
206
+
207
+
208
+ # only provides request.user.is_authenticated
209
+ class AuthBackend(AuthenticationBackend):
210
+ async def authenticate(self, conn: HTTPConnection):
211
+ userinfo = get_userinfo(conn)
212
+ if userinfo is None:
213
+ return AuthCredentials(), UnauthenticatedUser()
214
+ else:
215
+ username = "noname"
216
+ return AuthCredentials(["authenticated"]), SimpleUser(username)
217
+
218
+
219
+ oauth = OAuth(config)
220
+ oauth.register(name="pycafe_server")
221
+
222
+ app = Router()
223
+
224
+
225
+ @app.route("/_login")
226
+ async def login_via_pycafe(request: Request):
227
+ if "next_url" in request.query_params:
228
+ request.session["next_url"] = request.query_params["next_url"]
229
+ client: StarletteOAuth2App = oauth.create_client("pycafe_server")
230
+ redirect_uri = request.url_for("authorize_pycafe")
231
+ # we send the client id, since that might change during testing, and that means
232
+ # get_userinfo should not return a userinfo if the client id does not match
233
+ request.session["client_id"] = config.PYCAFE_SERVER_CLIENT_ID
234
+ return await client.authorize_redirect(request, redirect_uri)
235
+
236
+
237
+ @app.route("/_authorize")
238
+ async def authorize_pycafe(request: Request):
239
+ client: StarletteOAuth2App = oauth.create_client("pycafe_server")
240
+ base_url = str(request.base_url)
241
+ org_url = request.session.pop("next_url", base_url)
242
+ token = await client.authorize_access_token(request)
243
+ headers = MutableHeaders(scope=request.scope)
244
+
245
+ id_token = token.get("id_token")
246
+ if id_token is None:
247
+ raise ValueError("No id_token found in token")
248
+ id_token_cookie.set_dict(headers, id_token)
249
+ access_token = token.get("access_token")
250
+ if access_token is None:
251
+ raise ValueError("No access_token found in token")
252
+ access_token_cookie.set_dict(headers, access_token)
253
+ refresh_token = token.get("refresh_token")
254
+ if refresh_token is None:
255
+ refresh_token_cookie.clear(headers)
256
+ else:
257
+ refresh_token_cookie.set_dict(headers, refresh_token)
258
+ # we might be able to skip the userinfo, but we'd have to look into
259
+ # what authorize_access_token does exactly
260
+ userinfo = token.get("userinfo")
261
+ if userinfo is None:
262
+ raise ValueError(
263
+ "No userinfo found in token, did you forgot to configure PYCAFE_SERVER_CLIENT_KWARGS?"
264
+ )
265
+ userinfo_cookie.set_dict(headers, userinfo)
266
+ id_field = config.get("PYCAFE_SERVER_USER_ID_FIELD", default="email")
267
+ user_id = userinfo.get(id_field)
268
+ if user_id is None:
269
+ print(
270
+ f"no value found in userinfo with key PYCAFE_SERVER_USER_ID_FIELD={id_field}, possible fields are {list(userinfo.keys())}"
271
+ )
272
+ return JSONResponse(
273
+ {
274
+ "error": "Could not find a field in the auth data to uniquely describe the user, see server logs and configuration"
275
+ },
276
+ status_code=500,
277
+ )
278
+ else:
279
+ return RedirectResponse(url=org_url, headers=headers)
280
+
281
+
282
+ @app.route("/_logout")
283
+ async def logout(request: Request):
284
+ client_id = config.PYCAFE_SERVER_CLIENT_ID
285
+ logout_url = (
286
+ config.PYCAFE_SERVER_OAUTH_BASE_URL + config.PYCAFE_SERVER_OAUTH_LOGOUT_PATH
287
+ )
288
+ if "next_url" in request.query_params:
289
+ request.session["next_url"] = request.query_params["next_url"]
290
+ redirect_uri = request.url_for("logout_callback")
291
+ # there is no standard for logout urls, so we
292
+ # use the most common url parameters to get back to the /_logout_return endpoint
293
+ # works for auth0 and fief, maybe more?
294
+ return RedirectResponse(
295
+ f"{logout_url}?returnTo={redirect_uri}&redirect_uri={redirect_uri}&post_logout_redirect_uri={redirect_uri}&client_id={client_id}"
296
+ )
297
+
298
+
299
+ @app.route("/_logout_callback")
300
+ async def logout_callback(request: Request):
301
+ next_url = request.session.pop("next_url", "/")
302
+ # ideally, we only remove these:
303
+ headers = MutableHeaders()
304
+ access_token_cookie.clear(headers)
305
+ id_token_cookie.clear(headers)
306
+ refresh_token_cookie.clear(headers)
307
+ userinfo_cookie.clear(headers)
308
+ # authlib sometimes leaves some stuff in the session on failed logins
309
+ # so we clear it all
310
+ request.session.clear()
311
+ return RedirectResponse(next_url, headers=headers)