diff --git a/docs/framework/authentication.mdx b/docs/framework/authentication.mdx index 63e19bce5..f7f6d4062 100644 --- a/docs/framework/authentication.mdx +++ b/docs/framework/authentication.mdx @@ -9,6 +9,10 @@ The Writer Framework authentication module allows you to restrict access to your trigger authentication for certain pages exclusively. + + Static assets from Writer Framework exposed through `/static` and `/extensions` endpoints are not protected behind Authentication. + + ## Use Basic Auth Basic Auth is a simple authentication method that uses a username and password. Authentication configuration is done in the [server_setup.py module](/framework/custom-server). @@ -134,6 +138,21 @@ writer.serve.register_auth(oidc) +### App static assets + +Static assets in your application are inaccessible. You can use the `app_static_public` parameter to allow their usage. +When `app_static_public` is set to `True`, the static assets in your application are accessible without authentication. + +```python +oidc = writer.auth.Auth0( + client_id="xxxxxxx", + client_secret="xxxxxxxxxxxxx", + domain="xxx-xxxxx.eu.auth0.com", + host_url=os.getenv('HOST_URL', "http://localhost:5000"), + app_static_public=True +) +``` + ## User information in event handler When the `user_info` route is configured, user information will be accessible diff --git a/pyproject.toml b/pyproject.toml index 97e9e72e4..4ea7296ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "writer" -version = "0.7.3" +version = "0.7.4" description = "An open-source, Python framework for building feature-rich apps that are fully integrated with the Writer platform." authors = ["Writer, Inc."] readme = "README.md" diff --git a/src/writer/auth.py b/src/writer/auth.py index 2e90a8653..92ca5a6a7 100644 --- a/src/writer/auth.py +++ b/src/writer/auth.py @@ -176,6 +176,7 @@ class Oidc(Auth): scope: str = "openid email profile" callback_authorize: str = "authorize" url_userinfo: Optional[str] = None + app_static_public: bool = False authlib: OAuth2Session = None callback_func: Optional[Callable[[Request, str, dict], None]] = None # Callback to validate user authentication @@ -191,14 +192,25 @@ def register(self, redirect_url = urljoin(self.host_url, self.callback_authorize) host_url_path = urlpath(self.host_url) callback_authorize_path = urljoin(host_url_path, self.callback_authorize) - static_assets_path = urljoin(host_url_path, "static") + + auth_authorized_prefix_paths = [] + auth_authorized_routes = [callback_authorize_path] + + for asset_path in writer.serve.wf_root_static_assets(): + if asset_path.is_file(): + auth_authorized_routes.append(urljoin(host_url_path, asset_path.name)) + elif asset_path.is_dir(): + auth_authorized_prefix_paths.append(urljoin(host_url_path, asset_path.name)) + + if self.app_static_public is True: + auth_authorized_prefix_paths += [urljoin(host_url_path, "static"), urljoin(host_url_path, "extensions")] logger.debug(f"[auth] oidc - url redirect: {redirect_url}") logger.debug(f"[auth] oidc - endpoint authorize: {self.url_authorize}") logger.debug(f"[auth] oidc - endpoint token: {self.url_oauthtoken}") logger.debug(f"[auth] oidc - path: {host_url_path}") - logger.debug(f"[auth] oidc - authorize path: {callback_authorize_path}") - logger.debug(f"[auth] oidc - static asset path: {static_assets_path}") + logger.debug(f"[auth] oidc - auth authorized routes: {auth_authorized_routes}") + logger.debug(f"[auth] oidc - auth authorized prefix paths: {auth_authorized_prefix_paths}") self.authlib = OAuth2Session( client_id=self.client_id, client_secret=self.client_secret, @@ -215,7 +227,8 @@ def register(self, async def oidc_middleware(request: Request, call_next): session = request.cookies.get('session') - if session is not None or request.url.path in [callback_authorize_path] or request.url.path.startswith(static_assets_path): + is_one_of_url_prefix_allowed = any(request.url.path.startswith(url_prefix) for url_prefix in auth_authorized_prefix_paths) + if session is not None or request.url.path in auth_authorized_routes or is_one_of_url_prefix_allowed: response: Response = await call_next(request) return response else: @@ -259,7 +272,7 @@ async def route_callback(request: Request): }) -def Google(client_id: str, client_secret: str, host_url: str) -> Oidc: +def Google(client_id: str, client_secret: str, host_url: str, app_static_public = False) -> Oidc: """ Configure Google Social login configured through Client Id for Web application in Google Cloud Console. @@ -269,6 +282,7 @@ def Google(client_id: str, client_secret: str, host_url: str) -> Oidc: :param client_id: client id of Web application :param client_secret: client secret of Web application :param host_url: The URL of the Writer Framework application (for callback) + :param app_static_public: authorizes the exposure of the user's static assets (/static and /extensions) """ return Oidc( client_id=client_id, @@ -276,9 +290,11 @@ def Google(client_id: str, client_secret: str, host_url: str) -> Oidc: host_url=host_url, url_authorize="https://accounts.google.com/o/oauth2/auth", url_oauthtoken="https://oauth2.googleapis.com/token", - url_userinfo="https://www.googleapis.com/oauth2/v1/userinfo?alt=json") + url_userinfo="https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + app_static_public=app_static_public + ) -def Github(client_id: str, client_secret: str, host_url: str) -> Oidc: +def Github(client_id: str, client_secret: str, host_url: str, app_static_public = False) -> Oidc: """ Configure Github authentication. @@ -288,16 +304,20 @@ def Github(client_id: str, client_secret: str, host_url: str) -> Oidc: :param client_id: client id :param client_secret: client secret :param host_url: The URL of the Writer Framework application (for callback) + :param app_static_public: authorizes the exposure of the user's static assets (/static and /extensions) """ + return Oidc( client_id=client_id, client_secret=client_secret, host_url=host_url, url_authorize="https://github.com/login/oauth/authorize", url_oauthtoken="https://github.com/login/oauth/access_token", - url_userinfo="https://api.github.com/user") + url_userinfo="https://api.github.com/user", + app_static_public=app_static_public + ) -def Auth0(client_id: str, client_secret: str, domain: str, host_url: str) -> Oidc: +def Auth0(client_id: str, client_secret: str, domain: str, host_url: str, app_static_public = False) -> Oidc: """ Configure Auth0 application for authentication. @@ -308,14 +328,18 @@ def Auth0(client_id: str, client_secret: str, domain: str, host_url: str) -> Oid :param client_secret: client secret :param domain: Domain of the Auth0 application :param host_url: The URL of the Writer Framework application (for callback) + :param app_static_public: authorizes the exposure of the user's static assets (/static and /extensions) """ + return Oidc( client_id=client_id, client_secret=client_secret, host_url=host_url, url_authorize=f"https://{domain}/authorize", url_oauthtoken=f"https://{domain}/oauth/token", - url_userinfo=f"https://{domain}/userinfo") + url_userinfo=f"https://{domain}/userinfo", + app_static_public=app_static_public + ) def urlpath(url: str): """ diff --git a/src/writer/serve.py b/src/writer/serve.py index c65012522..3e466f39a 100644 --- a/src/writer/serve.py +++ b/src/writer/serve.py @@ -9,7 +9,7 @@ import typing from contextlib import asynccontextmanager from importlib.machinery import ModuleSpec -from typing import Any, Callable, Dict, List, Optional, Set, Union, cast +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Union, cast from urllib.parse import urlsplit import uvicorn @@ -593,7 +593,7 @@ def _mount_server_static_path(app: FastAPI, server_static_path: pathlib.Path) -> Writer Framework routes remain priority. A developer cannot come and overload them. """ app.get('/')(lambda: FileResponse(server_static_path.joinpath('index.html'))) - for f in server_static_path.glob('*'): + for f in wf_root_static_assets(): if f.is_file(): app.get(f"/{f.name}")(lambda: FileResponse(f)) if f.is_dir(): @@ -613,3 +613,22 @@ def _execute_server_setup_hook(user_app_path: str) -> None: def app_runner(asgi_app: WriterFastAPI) -> AppRunner: return asgi_app.state.app_runner + + +def wf_root_static_assets() -> List[pathlib.Path]: + """ + Lists the root writer Framework static assets. Some of them are files, some other are directories. + + >>> for f in wf_root_static_assets() + >>> print(f"{f.name}") + >>> # favicon.ico + >>> # assets + + """ + all_static_assets: List[pathlib.Path] = [] + server_path = pathlib.Path(__file__) + server_static_path = server_path.parent / "static" + for f in server_static_path.glob('*'): + all_static_assets.append(f) + + return all_static_assets