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.
- pycafe_server/__about__.py +4 -0
- pycafe_server/__init__.py +3 -0
- pycafe_server/asgi.py +345 -0
- pycafe_server/auth.py +311 -0
- pycafe_server/config.py +172 -0
- pycafe_server/cookie.py +73 -0
- pycafe_server/database.py +174 -0
- pycafe_server/license.py +99 -0
- pycafe_server/session.py +56 -0
- pycafe_server/sign.py +14 -0
- pycafe_server/static/404.html +1 -0
- pycafe_server/static/_next/static/-dsYd_IR6KZ1xCK1JGQIe/_buildManifest.js +1 -0
- pycafe_server/static/_next/static/-dsYd_IR6KZ1xCK1JGQIe/_ssgManifest.js +1 -0
- pycafe_server/static/_next/static/chunks/117-2d310aa1d3fedd11.js +2 -0
- pycafe_server/static/_next/static/chunks/144-86024bdc2248d212.js +1 -0
- pycafe_server/static/_next/static/chunks/250-778b8977023babac.js +1 -0
- pycafe_server/static/_next/static/chunks/36-fbf60547c50ddd6d.js +1 -0
- pycafe_server/static/_next/static/chunks/484.3e91a8877443b77f.js +1 -0
- pycafe_server/static/_next/static/chunks/684-92effdd3a90cbd04.js +2 -0
- pycafe_server/static/_next/static/chunks/686-7260ec324cabf91a.js +1 -0
- pycafe_server/static/_next/static/chunks/70-70f8b9c8e8b73c0d.js +1 -0
- pycafe_server/static/_next/static/chunks/83-e3a2a619fdeb083b.js +1 -0
- pycafe_server/static/_next/static/chunks/92-69451410f0d6b437.js +1 -0
- pycafe_server/static/_next/static/chunks/921-9ecc230270e58d5f.js +1 -0
- pycafe_server/static/_next/static/chunks/aaea2bcf-debd16af2e1ee8d8.js +1 -0
- pycafe_server/static/_next/static/chunks/app/_not-found/page-320d5b624e913067.js +1 -0
- pycafe_server/static/_next/static/chunks/app/admin/page-ad0c1dd3957e012a.js +1 -0
- pycafe_server/static/_next/static/chunks/app/layout-182dee2a35e31a08.js +1 -0
- pycafe_server/static/_next/static/chunks/app/page-a5c474768bb5f2e2.js +1 -0
- pycafe_server/static/_next/static/chunks/app/sign-in/page-4f90785e5fc83287.js +1 -0
- pycafe_server/static/_next/static/chunks/app/snippet/[type]/[version]/page-fb30ffc54630e8fd.js +1 -0
- pycafe_server/static/_next/static/chunks/app/view/page-e53355c1ed692ec4.js +1 -0
- pycafe_server/static/_next/static/chunks/bc0697a0-9b4145a23a803cb0.js +3 -0
- pycafe_server/static/_next/static/chunks/d8465030-fe21b9c8223e3143.js +1 -0
- pycafe_server/static/_next/static/chunks/fd9d1056-ad5bbc8af4b8126c.js +1 -0
- pycafe_server/static/_next/static/chunks/framework-00a8ba1a63cfdc9e.js +1 -0
- pycafe_server/static/_next/static/chunks/main-830d03220a87b2cf.js +1 -0
- pycafe_server/static/_next/static/chunks/main-app-fec1dc80d88c979a.js +1 -0
- pycafe_server/static/_next/static/chunks/pages/_app-15e2daefa259f0b5.js +1 -0
- pycafe_server/static/_next/static/chunks/pages/_error-28b803cb2479b966.js +1 -0
- pycafe_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- pycafe_server/static/_next/static/chunks/webpack-a25853d04f8b4fca.js +1 -0
- pycafe_server/static/_next/static/css/00810e171700dd8c.css +1 -0
- pycafe_server/static/_next/static/css/107ede932147228b.css +1 -0
- pycafe_server/static/_next/static/css/2c73632f60809884.css +1 -0
- pycafe_server/static/_next/static/css/70765a0735f6401f.css +1 -0
- pycafe_server/static/_next/static/css/ac106bbd0da9ef31.css +1 -0
- pycafe_server/static/_next/static/css/c35ecba93103ef73.css +1 -0
- pycafe_server/static/_next/static/media/26a46d62cd723877-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/55c55f0601d81cf3-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/581909926a08bbc8-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/6d93bde91c0c2823-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/97e0cb1ae144a2a9-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/a34f9d1faa5f3315-s.p.woff2 +0 -0
- pycafe_server/static/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
- pycafe_server/static/_next/static/media/material-icons-outlined.78a93b20.woff +0 -0
- pycafe_server/static/_next/static/media/material-icons-outlined.f86cb7b0.woff2 +0 -0
- pycafe_server/static/_next/static/media/material-icons-round.92dc7ca2.woff +0 -0
- pycafe_server/static/_next/static/media/material-icons-round.b10ec9db.woff2 +0 -0
- pycafe_server/static/_next/static/media/material-icons-sharp.3885863e.woff2 +0 -0
- pycafe_server/static/_next/static/media/material-icons-sharp.a71cb2bf.woff +0 -0
- pycafe_server/static/_next/static/media/material-icons-two-tone.588d6313.woff +0 -0
- pycafe_server/static/_next/static/media/material-icons-two-tone.675bd578.woff2 +0 -0
- pycafe_server/static/_next/static/media/material-icons.4ad034d2.woff +0 -0
- pycafe_server/static/_next/static/media/material-icons.59322316.woff2 +0 -0
- pycafe_server/static/admin.html +1 -0
- pycafe_server/static/admin.txt +9 -0
- pycafe_server/static/app.py +102 -0
- pycafe_server/static/backgrounds/image16.svg +9 -0
- pycafe_server/static/backgrounds/image17.svg +9 -0
- pycafe_server/static/backgrounds/image18.svg +9 -0
- pycafe_server/static/badge.svg +1 -0
- pycafe_server/static/bootstrap_jupyter_kernel.py +52 -0
- pycafe_server/static/coffeecup.gif +0 -0
- pycafe_server/static/dash_daq-0.5.0-py3-none-any.whl +0 -0
- pycafe_server/static/docs/apps/dash-app.webp +0 -0
- pycafe_server/static/docs/apps/dash-open.webp +0 -0
- pycafe_server/static/docs/apps/dash-preview.webp +0 -0
- pycafe_server/static/docs/apps/dash-share-app.webp +0 -0
- pycafe_server/static/docs/apps/dash-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/dash-share.webp +0 -0
- pycafe_server/static/docs/apps/dash-upload.webp +0 -0
- pycafe_server/static/docs/apps/panel-app.webp +0 -0
- pycafe_server/static/docs/apps/panel-open.webp +0 -0
- pycafe_server/static/docs/apps/panel-preview.webp +0 -0
- pycafe_server/static/docs/apps/panel-share-app.webp +0 -0
- pycafe_server/static/docs/apps/panel-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/panel-share.webp +0 -0
- pycafe_server/static/docs/apps/panel-upload.webp +0 -0
- pycafe_server/static/docs/apps/shiny-app.webp +0 -0
- pycafe_server/static/docs/apps/shiny-open.webp +0 -0
- pycafe_server/static/docs/apps/shiny-preview.webp +0 -0
- pycafe_server/static/docs/apps/shiny-share-app.webp +0 -0
- pycafe_server/static/docs/apps/shiny-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/shiny-share.webp +0 -0
- pycafe_server/static/docs/apps/shiny-upload.webp +0 -0
- pycafe_server/static/docs/apps/solara-app.webp +0 -0
- pycafe_server/static/docs/apps/solara-open.webp +0 -0
- pycafe_server/static/docs/apps/solara-preview.webp +0 -0
- pycafe_server/static/docs/apps/solara-share-app.webp +0 -0
- pycafe_server/static/docs/apps/solara-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/solara-share.webp +0 -0
- pycafe_server/static/docs/apps/solara-upload.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-app.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-open.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-preview.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-share-app.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-share.webp +0 -0
- pycafe_server/static/docs/apps/streamlit-upload.webp +0 -0
- pycafe_server/static/docs/apps/vizro-app.webp +0 -0
- pycafe_server/static/docs/apps/vizro-open.webp +0 -0
- pycafe_server/static/docs/apps/vizro-preview.webp +0 -0
- pycafe_server/static/docs/apps/vizro-share-app.webp +0 -0
- pycafe_server/static/docs/apps/vizro-share-embed.webp +0 -0
- pycafe_server/static/docs/apps/vizro-share.webp +0 -0
- pycafe_server/static/docs/apps/vizro-upload.webp +0 -0
- pycafe_server/static/docs/ipyreact-status.webp +0 -0
- pycafe_server/static/docs/secrets/secret-enter-value.webp +0 -0
- pycafe_server/static/docs/secrets/secret-manager.webp +0 -0
- pycafe_server/static/docs/secrets/secret-printed.webp +0 -0
- pycafe_server/static/docs/secrets/secret-prompt-with-reason.webp +0 -0
- pycafe_server/static/docs/secrets/secret-prompt.webp +0 -0
- pycafe_server/static/docs/secrets/secret-with-reason-enter-value.webp +0 -0
- pycafe_server/static/docs/secrets/secret-with-reason-printed.webp +0 -0
- pycafe_server/static/docs/vizro-ci.webp +0 -0
- pycafe_server/static/favicon.ico +0 -0
- pycafe_server/static/fs-worker.js +2 -0
- pycafe_server/static/fs-worker.js.LICENSE.txt +12 -0
- pycafe_server/static/google_crc32c-1.5.0-py3-none-any.whl +0 -0
- pycafe_server/static/icons/discord.svg +1 -0
- pycafe_server/static/icons/folder.svg +1 -0
- pycafe_server/static/icons/terminal.svg +1 -0
- pycafe_server/static/index.html +1 -0
- pycafe_server/static/index.txt +9 -0
- pycafe_server/static/ipykernel-6.9.2-py3-none-any.whl +0 -0
- pycafe_server/static/jupyterlite_pyodide_kernel-0.0.8-py3-none-any.whl +0 -0
- pycafe_server/static/logo.png +0 -0
- pycafe_server/static/logos/gradio_logo_full.svg +20 -0
- pycafe_server/static/logos/gradio_logo_full_dark.svg +23 -0
- pycafe_server/static/logos/panel_logo.png +0 -0
- pycafe_server/static/logos/panel_logo_full.png +0 -0
- pycafe_server/static/logos/panel_logo_full_dark.png +0 -0
- pycafe_server/static/logos/plotly_logo.png +0 -0
- pycafe_server/static/logos/plotly_logo_full.png +0 -0
- pycafe_server/static/logos/plotly_logo_full_white.png +0 -0
- pycafe_server/static/logos/plotly_logo_white.png +0 -0
- pycafe_server/static/logos/pycafe_logo.png +0 -0
- pycafe_server/static/logos/python.svg +265 -0
- pycafe_server/static/logos/shiny_logo.png +0 -0
- pycafe_server/static/logos/shiny_logo_full.svg +80 -0
- pycafe_server/static/logos/shiny_logo_full_white.svg +80 -0
- pycafe_server/static/logos/solara_logo.svg +8 -0
- pycafe_server/static/logos/streamlit_logo.png +0 -0
- pycafe_server/static/logos/streamlit_logo_full.svg +6 -0
- pycafe_server/static/logos/streamlit_logo_full_white.svg +6 -0
- pycafe_server/static/logos/vizro_logo.svg +3 -0
- pycafe_server/static/logos/vizro_logo_full.svg +9 -0
- pycafe_server/static/logos/vizro_logo_full_white.svg +9 -0
- pycafe_server/static/next.svg +1 -0
- pycafe_server/static/pyodide_kernel-0.0.8-py3-none-any.whl +0 -0
- pycafe_server/static/pyperclip-1.8.2-py3-none-any.whl +0 -0
- pycafe_server/static/qr_buy_eu.png +0 -0
- pycafe_server/static/qr_buy_us.png +0 -0
- pycafe_server/static/requirements.txt +16 -0
- pycafe_server/static/service-worker.js +1 -0
- pycafe_server/static/sign-in.html +1 -0
- pycafe_server/static/sign-in.txt +9 -0
- pycafe_server/static/snippet/dash/v1.html +1 -0
- pycafe_server/static/snippet/dash/v1.txt +10 -0
- pycafe_server/static/snippet/panel/v1.html +1 -0
- pycafe_server/static/snippet/panel/v1.txt +10 -0
- pycafe_server/static/snippet/shiny/v1.html +1 -0
- pycafe_server/static/snippet/shiny/v1.txt +10 -0
- pycafe_server/static/snippet/solara/v1.html +1 -0
- pycafe_server/static/snippet/solara/v1.txt +10 -0
- pycafe_server/static/snippet/streamlit/v1.html +1 -0
- pycafe_server/static/snippet/streamlit/v1.txt +10 -0
- pycafe_server/static/snippet/vizro/v1.html +1 -0
- pycafe_server/static/snippet/vizro/v1.txt +10 -0
- pycafe_server/static/test_dash.py +59 -0
- pycafe_server/static/test_ipykernel.py +40 -0
- pycafe_server/static/test_jupyterlab.py +51 -0
- pycafe_server/static/test_solara.py +77 -0
- pycafe_server/static/test_starlette.py +65 -0
- pycafe_server/static/test_streamlit.py +31 -0
- pycafe_server/static/test_tornado.py +94 -0
- pycafe_server/static/tor.py +18 -0
- pycafe_server/static/tornado-6.4.2-py3-none-any.whl +0 -0
- pycafe_server/static/vercel.svg +1 -0
- pycafe_server/static/view.html +1 -0
- pycafe_server/static/view.txt +9 -0
- pycafe_server/static/webworker.js +2 -0
- pycafe_server/static/webworker.js.LICENSE.txt +5 -0
- pycafe_server/uv_util.py +114 -0
- pycafe_server-0.0.7.dist-info/METADATA +60 -0
- pycafe_server-0.0.7.dist-info/RECORD +199 -0
- pycafe_server-0.0.7.dist-info/WHEEL +4 -0
- pycafe_server-0.0.7.dist-info/licenses/LICENSE.txt +9 -0
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)
|