diff --git a/MANIFEST.in b/MANIFEST.in index 5c0e7ce..030f507 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include LICENSE include CONTRIBUTING.md include README.md recursive-exclude * __pycache__ +include plash_cli/assets/es256_public_key.pem diff --git a/examples/auth/main.py b/examples/auth/main.py new file mode 100644 index 0000000..74bb88a --- /dev/null +++ b/examples/auth/main.py @@ -0,0 +1,34 @@ +from fasthtml.common import * +from plash_cli.auth import * + +app, rt = fast_app() + +@rt +def index(session): + if uid:=session.get('uid'): # <1> + return (H1(f"Welcome! You are logged in as user: {uid}"), + A("Logout", href="/logout")) + else: + return ( + H1("Welcome! Please sign."), + A("Sign in with Google", href=mk_signin_url(session))) # <2> + +@rt(signin_completed_rt) # <3> +def signin_completed(session, signin_reply: str): + try: + uid = goog_id_from_signin_reply(session, signin_reply) # <4> + session['uid'] = uid + return RedirectResponse('/', status_code=303) + except PlashAuthError as e: # <5> + return Div( + H2("Login Failed"), + P(f"There was an error signing you in: {e}"), + A("Try Again", href="/") + ) + +@rt('/logout') +def logout(session): + session.pop('uid') # <6> + return RedirectResponse('/', status_code=303) + +serve() diff --git a/examples/auth/requirements.txt b/examples/auth/requirements.txt new file mode 100644 index 0000000..cb94043 --- /dev/null +++ b/examples/auth/requirements.txt @@ -0,0 +1,2 @@ +python-fasthtml +plash-cli diff --git a/examples/with_db/data/file1.txt b/examples/with_db/data/file1.txt new file mode 100644 index 0000000..34ba233 --- /dev/null +++ b/examples/with_db/data/file1.txt @@ -0,0 +1 @@ +hey there! \ No newline at end of file diff --git a/nbs/01_auth.ipynb b/nbs/01_auth.ipynb new file mode 100644 index 0000000..8a439bb --- /dev/null +++ b/nbs/01_auth.ipynb @@ -0,0 +1,240 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e41f2262", + "metadata": {}, + "source": [ + "# Auth\n", + "\n", + "> Client side logic to add Plash Auth to your app" + ] + }, + { + "cell_type": "markdown", + "id": "188fb79d", + "metadata": {}, + "source": [ + "For an end2end example on how to use this module see `./examples/auth/main.py`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1d44038", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp auth" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6aa552a", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "import httpx,os,jwt\n", + "from pathlib import Path\n", + "from warnings import warn\n", + "\n", + "from plash_cli import __version__" + ] + }, + { + "cell_type": "markdown", + "id": "980c7492", + "metadata": {}, + "source": [ + "The signin completion route is where Google redirects users after authentication. Your app needs to add this route to handle the OAuth callback." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b15411c", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "signin_completed_rt = \"/signin_completed\"" + ] + }, + { + "cell_type": "markdown", + "id": "a4873761", + "metadata": {}, + "source": [ + "The production flag lets developers use mock authentication during development." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "718ef4d6", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_in_prod = os.getenv('PLASH_PRODUCTION', '') == '1'" + ] + }, + { + "cell_type": "markdown", + "id": "f15e27d0", + "metadata": {}, + "source": [ + "This function makes the actual HTTP request to the Plash authentication service. It sends the email and domain filters to get back a signin URL and request ID that we'll use to track this authentication attempt." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73a7d63b", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _signin_url(email_re: str=None, hd_re: str=None):\n", + " res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), \n", + " auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), \n", + " headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json()\n", + " if \"warning\" in res: warn(res.pop('warning'))\n", + " return res" + ] + }, + { + "cell_type": "markdown", + "id": "acf6707c", + "metadata": {}, + "source": [ + "This is the main function your app calls to get a Google signin URL. In development mode, it returns a mock URL to make testing easier. In production, it calls the Plash auth service and stores the request ID in the session for later verification." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74a0a24d", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def mk_signin_url(session: dict, # Session dictionary\n", + " email_re: str=None, # Regex filter for allowed email addresses\n", + " hd_re: str=None): # Regex filter for allowed Google hosted domains\n", + " \"Generate a Google Sign-In URL for Plash authentication.\"\n", + " if not _in_prod: return f\"{signin_completed_rt}?signin_reply=mock-sign-in-reply\"\n", + " res = _signin_url(email_re, hd_re)\n", + " session['req_id'] = res['req_id']\n", + " return res['plash_signin_url']" + ] + }, + { + "cell_type": "markdown", + "id": "1b33d30d", + "metadata": {}, + "source": [ + "After Google authentication, Plash sends back a JSON Web Token (JWT) containing the user's information. This function decodes and validates that token using the ES256 public key. If anything goes wrong with the JWT, it returns error details instead of crashing.\n", + "\n", + "Note: a JWT does not mean the message is encrypted. It ensures data integrity and authenticity, it protects against tampering and forgery. We use JWT tokens so your app can trust that the sign-in information and user details it receives after authentication really come from Plash (and by extension, Google), and have not been modified by an attacker." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e66bb6e", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _parse_jwt(reply: str) -> dict:\n", + " \"Parse JWT reply and return decoded claims or error info\"\n", + " try: decoded = jwt.decode(reply, key=open(Path(__file__).parent / \"assets\" / \"es256_public_key.pem\",\"rb\").read(), algorithms=[\"ES256\"], \n", + " options=dict(verify_aud=False, verify_iss=False))\n", + " except Exception as e: return dict(req_id=None, sub=None, err=f'JWT validation failed: {e}')\n", + " return dict(req_id=decoded.get('req_id'), sub=decoded.get('sub'), err=decoded.get('err'))" + ] + }, + { + "cell_type": "markdown", + "id": "6c1fe53f", + "metadata": {}, + "source": [ + "A custom exception for when authentication fails. This makes it easier for your app to handle auth errors specifically.\n", + "See `./examples/auth/main.py` for an example on how you can catch this exception in your application." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e61fbdd", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class PlashAuthError(Exception):\n", + " \"\"\"Raised when Plash authentication fails\"\"\"\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "62320729", + "metadata": {}, + "source": [ + "This is the main function your app calls in the signin completion route. It verifies the JWT reply matches the original request (preventing CSRF attacks), checks for any authentication errors, and returns the user's Google ID if everything is valid.\n", + "\n", + "When testing locally this will always return the mock Google ID `'424242424242424242424'`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a311ce9a", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def goog_id_from_signin_reply(session: dict, # Session dictionary containing 'req_id'\n", + " reply: str): # The JWT reply string from Plash after Google authentication\n", + " \"Validate Google sign-in reply and returns Google user ID if valid.\"\n", + " if not _in_prod: return '424242424242424242424'\n", + " parsed = _parse_jwt(reply)\n", + " if session.get('req_id') != parsed['req_id']: raise PlashAuthError(\"Request originated from a different browser than the one receiving the reply\")\n", + " if parsed['err']: raise PlashAuthError(f\"Authentication failed: {parsed['err']}\")\n", + " return parsed['sub']" + ] + }, + { + "cell_type": "markdown", + "id": "72eaabaa", + "metadata": {}, + "source": [ + "## Export -" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "640ce52d", + "metadata": {}, + "outputs": [], + "source": [ + "#|hide\n", + "from nbdev import nbdev_export\n", + "nbdev_export()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/how_to/04_auth.ipynb b/nbs/how_to/04_auth.ipynb new file mode 100644 index 0000000..41e7c37 --- /dev/null +++ b/nbs/how_to/04_auth.ipynb @@ -0,0 +1,285 @@ +{ + "cells": [ + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "---\n", + "execute:\n", + " output: false\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Auth\n", + "\n", + "> The easiest way to add Google OAuth authentication to your Plash apps" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Why Use Plash Auth?\n", + "\n", + "Setting up Google OAuth authentication traditionally requires:\n", + "\n", + "- Google Cloud Console project setup and OAuth consent screen configuration\n", + "- Secure credential management and rotation in production\n", + "- Managing redirect URLs across development, staging, and production environments\n", + "- Complex local testing workarounds (OAuth typically breaks without HTTPS and registered domains)\n", + "\n", + "**Plash Auth eliminates this complexity** by providing a simple wrapper around the OAuth flow. We handle all the Google Cloud setup, credential management, and redirect configuration for you." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 0. Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This tutorial will show you how to add Google OAuth authentication to your FastHTML apps deployed on Plash. With Plash's built-in auth system, you can easily implement secure sign-in functionality without managing OAuth secrets or redirect URLs yourself.\n", + "\n", + "**Prerequisites:**\n", + "\n", + "* A registered account at [https://pla.sh](https://pla.sh)\n", + "* Completed the [basic tutorial](../index.qmd) for deploying your first app" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this tutorial we'll focus on FastHTML. But any Plash app can technically make use of Plash Auth." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1. Create Your Auth App\n", + "\n", + "First create a new directory for our auth example.\n", + "\n", + "```bash\n", + "cd auth-example-app\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/rensdimmendaal/git/repos/plash_cli/examples/auth\n" + ] + } + ], + "source": [ + "#| hide\n", + "%cd ../../examples/auth/" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Create your app\n", + "\n", + "Create a `main.py` file for your app and paste in the minimum working example from below:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Overwriting main.py\n" + ] + } + ], + "source": [ + "%%writefile main.py\n", + "from fasthtml.common import *\n", + "from plash_cli.auth import *\n", + "\n", + "app, rt = fast_app()\n", + "\n", + "@rt\n", + "def index(session):\n", + " if uid:=session.get('uid'): # <1>\n", + " return (H1(f\"Welcome! You are logged in as user: {uid}\"), \n", + " A(\"Logout\", href=\"/logout\"))\n", + " else: \n", + " return (\n", + " H1(\"Welcome! Please sign.\"), \n", + " A(\"Sign in with Google\", href=mk_signin_url(session))) # <2>\n", + "\n", + "@rt(signin_completed_rt) # <3>\n", + "def signin_completed(session, signin_reply: str):\n", + " try: \n", + " uid = goog_id_from_signin_reply(session, signin_reply) # <4>\n", + " session['uid'] = uid\n", + " return RedirectResponse('/', status_code=303)\n", + " except PlashAuthError as e: # <5> \n", + " return Div(\n", + " H2(\"Login Failed\"),\n", + " P(f\"There was an error signing you in: {e}\"),\n", + " A(\"Try Again\", href=\"/\")\n", + " )\n", + " \n", + "@rt('/logout')\n", + "def logout(session):\n", + " session.pop('uid') # <6>\n", + " return RedirectResponse('/', status_code=303)\n", + "\n", + "serve()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "1. Verify if user is logged in\n", + "2. Generate Auth login URL\n", + "3. Receive Auth callback\n", + "4. Extract user ID from succesful Auth response\n", + "5. Handle Auth authentication errors\n", + "6. Clear user session to log out" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Add requirements\n", + "\n", + "Create your `requirements.txt` file with the necessary packages. Now you'll need to add the `plash-cli` package also to your app." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Overwriting requirements.txt\n" + ] + } + ], + "source": [ + "%%writefile requirements.txt\n", + "python-fasthtml\n", + "plash-cli" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4. Deploy Your Auth App\n", + "\n", + "With those two files created. Now we are ready to deploy.\n", + "\n", + "```bash\n", + "plash_deploy\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5. Try it out!\n", + "\n", + "Visit your deployed app:\n", + "\n", + "```bash\n", + "plash_view\n", + "```\n", + "\n", + "Test the authentication flow:\n", + "\n", + "1. **Sign in** → redirects to Google OAuth\n", + "2. **Grant permission** → returns to your app with user ID\n", + "3. **Session management** → handled by FastHTML sessions\n", + "4. **Logout** → clears session" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next steps" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Local use\n", + "\n", + "Plash Auth only works when deployed on Plash. When you run locally with `python main.py`, you'll get a test user with ID `424242424242424242424` for development.\n", + "\n", + "If you need realistic authentication testing, deploy a development version (e.g. `dev-my-app.pla.sh`) since Plash deployments are fast and low-cost." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Restricting access\n", + "\n", + "\n", + "\n", + "With the tutorial example above, anyone can login to your app. If you want to restrict access to your app, you can provide email or domain filters to the `mk_signin_url` function using the `email_re` parameter (to match specific email addresses) or `hd_re` parameter (to match Google hosted domains like your organization's domain)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### User data access\n", + "\n", + "Plash Auth only provides the user's unique Google ID. If you need additional user information (name, email) or Google service access (Drive, Gmail), you'll need to implement full OAuth yourself using [FastHTML's OAuth documentation](https://www.fastht.ml/docs/explains/oauth.html).\n", + "\n", + "**For most applications that just need secure user authentication, Plash Auth is the simplest solution.**" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/nbs/sidebar.yml b/nbs/sidebar.yml index b39c2b9..3157b49 100644 --- a/nbs/sidebar.yml +++ b/nbs/sidebar.yml @@ -3,6 +3,7 @@ website: contents: - index.ipynb - 00_core.ipynb + - 01_auth.ipynb - section: explains contents: - explains/00_docker_caching.ipynb @@ -14,6 +15,7 @@ website: - how_to/01_add_dependencies.ipynb - how_to/02_add_custom_domain.ipynb - how_to/03_restore_backups.ipynb + - how_to/04_auth.ipynb - section: reference contents: - reference/00_cli.ipynb diff --git a/plash_cli/__init__.py b/plash_cli/__init__.py index d31c31e..493f741 100644 --- a/plash_cli/__init__.py +++ b/plash_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.2.3" +__version__ = "0.3.0" diff --git a/plash_cli/_modidx.py b/plash_cli/_modidx.py index e426361..5e7c581 100644 --- a/plash_cli/_modidx.py +++ b/plash_cli/_modidx.py @@ -5,7 +5,12 @@ 'doc_host': 'https://AnswerDotAI.github.io', 'git_url': 'https://github.com/AnswerDotAI/plash_cli', 'lib_path': 'plash_cli'}, - 'syms': { 'plash_cli.core': { 'plash_cli.core.PlashError': ('core.html#plasherror', 'plash_cli/core.py'), + 'syms': { 'plash_cli.auth': { 'plash_cli.auth.PlashAuthError': ('auth.html#plashautherror', 'plash_cli/auth.py'), + 'plash_cli.auth._parse_jwt': ('auth.html#_parse_jwt', 'plash_cli/auth.py'), + 'plash_cli.auth._signin_url': ('auth.html#_signin_url', 'plash_cli/auth.py'), + 'plash_cli.auth.goog_id_from_signin_reply': ('auth.html#goog_id_from_signin_reply', 'plash_cli/auth.py'), + 'plash_cli.auth.mk_signin_url': ('auth.html#mk_signin_url', 'plash_cli/auth.py')}, + 'plash_cli.core': { 'plash_cli.core.PlashError': ('core.html#plasherror', 'plash_cli/core.py'), 'plash_cli.core._deps': ('core.html#_deps', 'plash_cli/core.py'), 'plash_cli.core._gen_app_name': ('core.html#_gen_app_name', 'plash_cli/core.py'), 'plash_cli.core.apps': ('core.html#apps', 'plash_cli/core.py'), diff --git a/plash_cli/assets/es256_public_key.pem b/plash_cli/assets/es256_public_key.pem new file mode 100644 index 0000000..47fc81d --- /dev/null +++ b/plash_cli/assets/es256_public_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEmAlaJd3pPsLNDxMf+gG1e+0DfSnS +mfrJiP5rgj8GL/xwhALJl9DOrw0gBh1H3Q2/XQvs+Df0rWXJ5bryn2ZPmg== +-----END PUBLIC KEY----- diff --git a/plash_cli/auth.py b/plash_cli/auth.py new file mode 100644 index 0000000..ad7f552 --- /dev/null +++ b/plash_cli/auth.py @@ -0,0 +1,60 @@ +"""Client side logic to add Plash Auth to your app""" + +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_auth.ipynb. + +# %% auto 0 +__all__ = ['signin_completed_rt', 'mk_signin_url', 'PlashAuthError', 'goog_id_from_signin_reply'] + +# %% ../nbs/01_auth.ipynb 3 +import httpx,os,jwt +from pathlib import Path +from warnings import warn + +from . import __version__ + +# %% ../nbs/01_auth.ipynb 5 +signin_completed_rt = "/signin_completed" + +# %% ../nbs/01_auth.ipynb 7 +_in_prod = os.getenv('PLASH_PRODUCTION', '') == '1' + +# %% ../nbs/01_auth.ipynb 9 +def _signin_url(email_re: str=None, hd_re: str=None): + res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), + auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), + headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json() + if "warning" in res: warn(res.pop('warning')) + return res + +# %% ../nbs/01_auth.ipynb 11 +def mk_signin_url(session: dict, # Session dictionary + email_re: str=None, # Regex filter for allowed email addresses + hd_re: str=None): # Regex filter for allowed Google hosted domains + "Generate a Google Sign-In URL for Plash authentication." + if not _in_prod: return f"{signin_completed_rt}?signin_reply=mock-sign-in-reply" + res = _signin_url(email_re, hd_re) + session['req_id'] = res['req_id'] + return res['plash_signin_url'] + +# %% ../nbs/01_auth.ipynb 13 +def _parse_jwt(reply: str) -> dict: + "Parse JWT reply and return decoded claims or error info" + try: decoded = jwt.decode(reply, key=open(Path(__file__).parent / "assets" / "es256_public_key.pem","rb").read(), algorithms=["ES256"], + options=dict(verify_aud=False, verify_iss=False)) + except Exception as e: return dict(req_id=None, sub=None, err=f'JWT validation failed: {e}') + return dict(req_id=decoded.get('req_id'), sub=decoded.get('sub'), err=decoded.get('err')) + +# %% ../nbs/01_auth.ipynb 15 +class PlashAuthError(Exception): + """Raised when Plash authentication fails""" + pass + +# %% ../nbs/01_auth.ipynb 17 +def goog_id_from_signin_reply(session: dict, # Session dictionary containing 'req_id' + reply: str): # The JWT reply string from Plash after Google authentication + "Validate Google sign-in reply and returns Google user ID if valid." + if not _in_prod: return '424242424242424242424' + parsed = _parse_jwt(reply) + if session.get('req_id') != parsed['req_id']: raise PlashAuthError("Request originated from a different browser than the one receiving the reply") + if parsed['err']: raise PlashAuthError(f"Authentication failed: {parsed['err']}") + return parsed['sub'] diff --git a/settings.ini b/settings.ini index a40bd70..f87704b 100644 --- a/settings.ini +++ b/settings.ini @@ -2,7 +2,7 @@ jupyter_hooks = False repo = plash_cli lib_name = plash_cli -version = 0.2.3 +version = 0.3.0 min_python = 3.11 license = apache2 black_formatting = False @@ -27,7 +27,7 @@ keywords = nbdev jupyter notebook python language = English status = 3 user = AnswerDotAI -requirements = fastcore httpx>=0.28.1 +requirements = fastcore httpx>=0.28.1 python-dotenv pyjwt cryptography dev_requirements = bash_kernel nbdev console_scripts = plash_deploy=plash_cli.core:deploy plash_login=plash_cli.core:login