├── .gitignore ├── README.md ├── app ├── 1_get_user_credentials.py ├── 2_run_local_server.py ├── 3_fastapi_redirect.py ├── 4_fastapi_session_cookies.py ├── 5_google_signin_component.py └── 6_firebase_signin_component.py ├── fastapi_server.py ├── images ├── 1-get_user_credentials.png ├── 2-FastAPI.png ├── google.png ├── oauth_creds.png └── oauth_scopes.png ├── requirements.txt ├── streamlit_app.py ├── streamlit_firebase_signin ├── __init__.py ├── index.html ├── index.js └── streamlit-component-lib.js └── streamlit_google_signin ├── __init__.py ├── index.html ├── index.js └── streamlit-component-lib.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # All secrets 27 | *.json 28 | *.pickle 29 | .streamlit 30 | 31 | # IDEs 32 | .idea 33 | .vscode 34 | 35 | # Ruff 36 | .ruff_cache 37 | 38 | server_user_id.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local exploration of Google Authentication in Streamlit 2 | 3 | Playing with multiple ways of authenticating through a Google account within Streamlit: 4 | 5 | - Decomposing server-side OAuth flow through [Google Auth Oauthlib](https://googleapis.dev/python/google-auth-oauthlib/latest/index.html) and [FastAPI](https://fastapi.tiangolo.com/) to catch redirect URIs and manage session cookies 6 | - [Google Signin Button](https://developers.google.com/identity/gsi/web/guides/display-button#javascript) in a Streamlit Component 7 | - [Firebase Authentication](https://firebase.google.com/docs/auth) with [Firebase UI](https://firebase.google.com/docs/auth/web/firebaseui) in a Streamlit Component 8 | 9 | ## Prerequisites 10 | 11 | ### Initialization 12 | 13 | - A Google Project. Initialize one from the [GCP Console](https://console.cloud.google.com/) 14 | - A Firebase Project. To keep things tidy, create a Firebase project over the previously created GCP Project through the [Firebase Console](https://console.firebase.google.com/) 15 | 16 | ### Streamlit configuration 17 | 18 | - For practice's sake, [enable local HTTPS](https://docs.streamlit.io/develop/concepts/configuration/https-support) for your Streamlit app 19 | - I ran `openssl req -x509 -newkey rsa:4096 -nodes -out cert.pem -keyout key.pem -days 365` in WSL2 on Windows. 20 | - Then moved `cert.pem` and `key.pem` to `.streamlit` folder at root of project 21 | - Finally added `sslCertFile = ".streamlit/cert.pem"` and `sslKeyFile = ".streamlit/key.pem"` to `.streamlit/config.toml` 22 | 23 | ```toml 24 | [server] 25 | sslCertFile = ".streamlit/cert.pem" 26 | sslKeyFile = ".streamlit/key.pem" 27 | ``` 28 | 29 | _Better use [Caddy](https://caddyserver.com/) or [Nginx](https://nginx.org/en/) to SSL proxify Streamlit & FastAPI_ 30 | 31 | ### OAuth configuration 32 | 33 | Head to [GCP Console](https://console.cloud.google.com/) 34 | 35 | - In **APIs and services > OAuth consent screen**: 36 | 37 | - configure email, links on first page. 38 | - in Scopes second page, add OpenID Connect scopes: `openid`, `../auth/userinfo.profile` and `.../auth/userinfo.email`. 39 | - If you enabled the `Calendar API` (either by typing it in search bar or from **APIs and services >Enabled APIs and Services**), add the `.../auth/calendar.events.readonly` to ask the user for Calendar authorization 40 | 41 |  42 | 43 | - add tests users in 3rd page 44 | 45 | - In **APIs and services > Credentials**, create a OAuth 2.0 Web Client ID and download it as a JSON file `client_secret.json` to the root of the project. 46 | - As authorised Javascript origins, I added the local Streamlit URL: `https://localhost:8501` 47 | - As authorised redirect URIs, I added: 48 | - `http://localhost:9000/`: Flask endpoint from google-oauthlib's `InstalledAppFlow.run_local_server` and `get_user_credentials` methods 49 | - `http://localhost:8000/auth/code` and `http://localhost:8000/auth/token`: our own FastAPI callback endpoints 50 | 51 |  52 | 53 | - Copy the credentials `client_id` and `client_secret` to `.streamlit/secrets.toml` file 54 | 55 | ```toml 56 | client_id="XXX.apps.googleusercontent.com" 57 | client_secret="XXXXXX-..." 58 | ``` 59 | 60 | --- 61 | 62 | Next, head to [Firebase Console](https://console.firebase.google.com) 63 | 64 | - In **Firebase Authentication > Sign-in method**, enable Google Provider. 65 | - In **Project Overview > Project Settings > Service Acctouns**, generate a new private key and download JSON file as `firebase_secret.json` to the root of the project. 66 | - In **Project Overview**, click the `Add app` button, and add a `Web` App. After creation, copy the `firebaseConfig` variable into a `firebase_client.json` file at the root of the project. 67 | 68 | ## Install 69 | 70 | Install Python packages: `pip install -r requirements.txt` 71 | 72 | ## Run 73 | 74 | Run Streamlit multipage app: `streamlit run streamlit_app.py` 75 | 76 | - Access Streamlit app in `https://localhost:8501` 77 | 78 | For pages 3 and 4, run FastAPI redirect server in parallel: `fastapi dev fastapi_server.py`. 79 | 80 | - Access FastAPI OpenAPI in `http://localhost:8000/docs`. Visualize state of app in `/sessions` URI. 81 | 82 | ### Page 1 & 2: google-oauthlib run_local_server 83 | 84 |  85 | 86 | * Good enough for local / on-premise deployments 87 | * Wouldn't work on Streamlit Cloud because the Flask port is not open. Create your own Docker image to expose Streamlit + Flask ports 88 | 89 | ### Page 3 & 4: Catch redirect with custom FastAPI endpoint 90 | 91 |  92 | 93 | * Deploy FastAPI separately as authentication and session cookie management service 94 | * Hide both services behind reverse proxy with single URL 95 | * I think it's a difficult setup to maintain. FastAPI + DB session cookies to replace with Firebase Authentication or Auth0 if you don't have time for this like me... 96 | * When Native OAuth+OIDC, redirects and custom endpoints appear in Streamlit, this solution seems the best 97 | 98 | ### Page 5 & 6: Frontend signin 99 | 100 | * Streamlit Component which embeds Google / Firebase signin 101 | * Google signin stores SIDCC cookie, Firebase uses API Keys+JWT in IndexedDB on browser to track user session 102 | * Because of Streamlit Component iframe embed, doesn't work in deployments because CSRF issues. Streamlit host and iframe embedding signin have different addresses. 103 | * Google Tap has [intermediate Iframe](https://developers.google.com/identity/gsi/web/amp/intermediate-iframe) that may solve this? -------------------------------------------------------------------------------- /app/1_get_user_credentials.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import streamlit as st 4 | from google.auth.transport import requests 5 | from google.oauth2 import id_token 6 | from google_auth_oauthlib import get_user_credentials 7 | from googleapiclient.discovery import build 8 | from googleapiclient.errors import HttpError 9 | 10 | 11 | if "user" not in st.session_state: 12 | st.session_state.user = None 13 | if "credentials" not in st.session_state: 14 | st.session_state.credentials = None 15 | 16 | 17 | def login_callback(): 18 | credentials = get_user_credentials( 19 | scopes=[ 20 | "openid", 21 | "https://www.googleapis.com/auth/userinfo.email", 22 | "https://www.googleapis.com/auth/userinfo.profile", 23 | "https://www.googleapis.com/auth/calendar.events.readonly", 24 | ], 25 | client_id=st.secrets.client_id, 26 | client_secret=st.secrets.client_secret, 27 | # limit redirect URI server to http://localhost:9000 28 | minimum_port=9000, 29 | maximum_port=9001, 30 | ) 31 | id_info = id_token.verify_token( 32 | credentials.id_token, 33 | requests.Request(), 34 | ) 35 | st.session_state.credentials = credentials 36 | st.session_state.user = id_info 37 | 38 | 39 | if not st.session_state.user: 40 | st.button( 41 | "🔑 Login with Google", 42 | type="primary", 43 | on_click=login_callback, 44 | ) 45 | st.stop() 46 | 47 | if st.sidebar.button("Logout", type="primary"): 48 | st.session_state["user"] = None 49 | st.session_state["credentials"] = None 50 | st.rerun() 51 | 52 | st.header(f"Hello {st.session_state.user['given_name']}") 53 | st.image(st.session_state.user["picture"]) 54 | 55 | with st.sidebar: 56 | st.subheader("User info") 57 | st.json(st.session_state.user) 58 | 59 | st.divider() 60 | 61 | with st.expander("Upcoming Events in Google Calendar"): 62 | try: 63 | service = build("calendar", "v3", credentials=st.session_state.credentials) 64 | 65 | # Call the Calendar API for the next 10 events 66 | now = datetime.now().isoformat() + "Z" 67 | events_result = ( 68 | service.events() 69 | .list( 70 | calendarId="primary", 71 | timeMin=now, 72 | maxResults=10, 73 | singleEvents=True, 74 | orderBy="startTime", 75 | ) 76 | .execute() 77 | ) 78 | events = events_result.get("items", []) 79 | 80 | if not events: 81 | st.info("No upcoming events found", icon="ℹ️") 82 | st.stop() 83 | 84 | for event in events: 85 | start = event["start"].get("dateTime", event["start"].get("date")) 86 | st.markdown(f":blue[{start}] - **{event['summary']}**") 87 | 88 | except HttpError as error: 89 | st.error(f"An error occurred: {error}") 90 | -------------------------------------------------------------------------------- /app/2_run_local_server.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | from google.auth.transport import requests 3 | from google.oauth2 import id_token 4 | from google_auth_oauthlib.flow import InstalledAppFlow 5 | 6 | if "user" not in st.session_state: 7 | st.session_state.user = None 8 | if "credentials" not in st.session_state: 9 | st.session_state.credentials = None 10 | 11 | flow = InstalledAppFlow.from_client_secrets_file( 12 | "./client_secret.json", 13 | scopes=[ 14 | "openid", 15 | "https://www.googleapis.com/auth/userinfo.email", 16 | "https://www.googleapis.com/auth/userinfo.profile", 17 | "https://www.googleapis.com/auth/calendar.events.readonly", 18 | ], 19 | redirect_uri="http://localhost:9000/", 20 | ) 21 | 22 | 23 | def login_callback(): 24 | credentials = flow.run_local_server( 25 | # bind_addr="0.0.0.0", # so it works in Docker container 26 | port=9000, 27 | open_browser=True, # pass to False in container 28 | success_message="Authentication Complete. You can go back to the app if this window is not automatically closed.", 29 | prompt="login", # force relogin for demo purpose. Choice between none, login, select_account and consent 30 | ) 31 | id_info = id_token.verify_token( 32 | credentials.id_token, 33 | requests.Request(), 34 | ) 35 | st.session_state.credentials = credentials 36 | st.session_state.user = id_info 37 | 38 | 39 | if not st.session_state.user: 40 | st.button( 41 | "🔑 Login with Google", 42 | type="primary", 43 | on_click=login_callback, 44 | ) 45 | st.stop() 46 | 47 | if st.sidebar.button("Logout", type="primary"): 48 | st.session_state["user"] = None 49 | st.session_state["credentials"] = None 50 | st.rerun() 51 | 52 | st.header(f"Hello {st.session_state.user['given_name']}") 53 | st.image(st.session_state.user["picture"]) 54 | 55 | with st.sidebar: 56 | st.subheader("User info") 57 | st.json(st.session_state.user) 58 | -------------------------------------------------------------------------------- /app/3_fastapi_redirect.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import streamlit as st 4 | from google.auth.transport import requests 5 | from google.oauth2 import id_token 6 | from google_auth_oauthlib.flow import Flow 7 | 8 | if "user" not in st.session_state: 9 | st.session_state.user = None 10 | if "credentials" not in st.session_state: 11 | st.session_state.credentials = None 12 | 13 | scopes = [ 14 | "openid", 15 | "https://www.googleapis.com/auth/userinfo.email", 16 | "https://www.googleapis.com/auth/userinfo.profile", 17 | ] 18 | 19 | 20 | @st.cache_resource 21 | def get_flow(): 22 | flow = Flow.from_client_secrets_file( 23 | "./client_secret.json", 24 | scopes=scopes, 25 | redirect_uri="http://localhost:8000/auth/code", 26 | ) 27 | return flow 28 | 29 | 30 | @st.cache_data 31 | def get_auth_url(_flow: Flow): 32 | auth_url, state = _flow.authorization_url( 33 | prompt="consent", # force consent for demo purpose. Choice between none, login, select_account and consent 34 | ) 35 | return (auth_url, state) 36 | 37 | 38 | flow = get_flow() 39 | auth_url, state = get_auth_url(flow) 40 | 41 | if "auth_url" not in st.session_state: 42 | st.session_state.auth_url = auth_url 43 | if "state" not in st.session_state: 44 | st.session_state.state = state 45 | 46 | if not st.session_state.user: 47 | st.link_button( 48 | "🔑 Login with Google", 49 | url=st.session_state.auth_url, 50 | type="primary", 51 | ) 52 | 53 | # The user will get an authorization code. 54 | # This code is used to get the access token. 55 | with st.form("token_fetch"): 56 | c1, c2 = st.columns((4, 1), vertical_alignment="bottom") 57 | code_input = c1.text_input("Enter the authorization code: ") 58 | code_submit = c2.form_submit_button("Submit code") 59 | 60 | if code_submit: 61 | flow.fetch_token(code=code_input.rstrip()) 62 | credentials = flow.credentials 63 | id_info = id_token.verify_token( 64 | credentials.id_token, 65 | requests.Request(), 66 | ) 67 | st.session_state.credentials = credentials 68 | st.session_state.user = id_info 69 | 70 | 71 | if st.sidebar.button("Logout", type="primary", disabled=not st.session_state.user): 72 | st.session_state["user"] = None 73 | st.session_state["credentials"] = None 74 | st.session_state["auth_flow"] = None 75 | st.session_state["state"] = None 76 | st.rerun() 77 | 78 | if not st.session_state.user: 79 | st.stop() 80 | 81 | st.header(f"Hello {st.session_state.user['given_name']}") 82 | st.image(st.session_state.user["picture"]) 83 | 84 | with st.sidebar: 85 | st.subheader("User info") 86 | st.json(st.session_state.user) 87 | 88 | st.subheader( 89 | f"ID Token expires on: {datetime.fromtimestamp(st.session_state.user['exp'])}" 90 | ) 91 | 92 | # You can use flow.credentials, or you can get a requests session 93 | # using flow.authorized_session. 94 | authorized_session = flow.authorized_session() 95 | with st.expander("Get Info using authorized requests session"): 96 | st.json( 97 | authorized_session.get("https://www.googleapis.com/userinfo/v2/me").json(), 98 | ) 99 | -------------------------------------------------------------------------------- /app/4_fastapi_session_cookies.py: -------------------------------------------------------------------------------- 1 | import html 2 | 3 | import requests 4 | import streamlit as st 5 | from requests.exceptions import HTTPError 6 | 7 | if "user" not in st.session_state: 8 | st.session_state.user = None 9 | 10 | 11 | def st_redirect(url): 12 | source = f"location.href = '{url}'" 13 | wrapped_source = f"(async () => {{{source}}})()" 14 | st.markdown( 15 | f""" 16 |
27 | """, 28 | unsafe_allow_html=True, 29 | ) 30 | 31 | 32 | # First, look for session cookie 33 | if "__streamlit_session" not in st.context.cookies: 34 | if st.button("🔑 Login with Google", type="primary"): 35 | with st.spinner("Creating new session"): 36 | r = requests.post("http://localhost:8000/sessions") 37 | r.raise_for_status() 38 | resp = r.json() 39 | st_redirect(resp["auth_url"]) 40 | st.stop() 41 | 42 | # state from cookie after FastAPI redirect, try to get user 43 | if "__streamlit_session" in st.context.cookies and not st.session_state.user: 44 | try: 45 | r = requests.get( 46 | f"http://localhost:8000/sessions/{st.context.cookies['__streamlit_session']}" 47 | ) 48 | r.raise_for_status() 49 | resp = r.json() 50 | st.session_state.user = resp 51 | except HTTPError as exc: 52 | # I assume session got revoked so just destroy the cookie 😅 53 | st_redirect(f"http://localhost:8000/delete-cookie") 54 | 55 | 56 | if not st.session_state.user: 57 | st.stop() 58 | 59 | st.header(f"Hello {st.session_state.user['given_name']}") 60 | st.image(st.session_state.user["picture"]) 61 | 62 | if st.sidebar.button("Logout", type="primary"): 63 | st.session_state.user = None 64 | r = requests.delete( 65 | f"http://localhost:8000/sessions/{st.context.cookies['__streamlit_session']}" 66 | ) 67 | st_redirect(f"http://localhost:8000/delete-cookie") 68 | 69 | with st.sidebar: 70 | st.subheader("User info") 71 | st.json(st.session_state.user) 72 | -------------------------------------------------------------------------------- /app/5_google_signin_component.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | 3 | from streamlit_google_signin import st_google_signin 4 | 5 | if "user" not in st.session_state: 6 | st.session_state.user = None 7 | 8 | if not st.session_state.user: 9 | token = st_google_signin(st.secrets.client_id) 10 | if token is None: 11 | st.stop() 12 | st.session_state.user = token 13 | 14 | if not st.session_state.user: 15 | st.stop() 16 | 17 | if st.sidebar.button("Logout", type="primary"): 18 | st.session_state["user"] = None 19 | st.session_state["credentials"] = None 20 | st.rerun() 21 | 22 | st.header(f"Hello {st.session_state.user['given_name']}") 23 | st.image(st.session_state.user["picture"]) 24 | 25 | with st.sidebar: 26 | st.subheader("User info") 27 | st.json(st.session_state.user) 28 | -------------------------------------------------------------------------------- /app/6_firebase_signin_component.py: -------------------------------------------------------------------------------- 1 | import json 2 | import streamlit as st 3 | 4 | from streamlit_firebase_signin import st_firebase_signin 5 | 6 | if "user" not in st.session_state: 7 | st.session_state.user = None 8 | 9 | with open("firebase_client.json", "r") as f: 10 | firebase_config = json.load(f) 11 | 12 | if not st.session_state.user: 13 | token = st_firebase_signin(firebase_config) 14 | if token is None: 15 | st.stop() 16 | st.session_state.user = token 17 | 18 | if not st.session_state.user: 19 | st.stop() 20 | 21 | if st.sidebar.button("Logout", type="primary"): 22 | st.session_state["user"] = None 23 | st.session_state["credentials"] = None 24 | st.rerun() 25 | 26 | st.header(f"Hello {st.session_state.user['name']}") 27 | st.image(st.session_state.user["picture"]) 28 | 29 | with st.sidebar: 30 | st.subheader("User info") 31 | st.json(st.session_state.user) 32 | -------------------------------------------------------------------------------- /fastapi_server.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from datetime import datetime 3 | from datetime import timedelta 4 | from datetime import timezone 5 | 6 | from fastapi import FastAPI 7 | from fastapi import status 8 | from fastapi.exceptions import HTTPException 9 | from fastapi.responses import HTMLResponse 10 | from fastapi.responses import RedirectResponse 11 | from fastapi.responses import Response 12 | from google.auth.transport import requests 13 | from google.oauth2 import id_token 14 | from google_auth_oauthlib.flow import Flow 15 | 16 | 17 | @asynccontextmanager 18 | async def lifespan(app: FastAPI): 19 | # This should be a remote Redis or Firestore... 20 | # for expiry and horizontal scalability 21 | app.state.fake_sessions = {} 22 | yield 23 | 24 | 25 | app = FastAPI(title="My Google OAuth middleware", lifespan=lifespan) 26 | 27 | 28 | @app.get("/") 29 | def hello_world(): 30 | return {"Hello": "World"} 31 | 32 | 33 | @app.get( 34 | "/auth/code", 35 | summary="Parses the redirect URI for authorization code", 36 | description="Passed as redirect URI from Google. Uses state for session validation", 37 | tags=["oauth2"], 38 | ) 39 | def callback_parse_redirect(state: str, code: str): 40 | return HTMLResponse(f""" 41 | 42 | 43 |