Spaces:
Sleeping
Sleeping
Commit ·
1b35f8e
1
Parent(s): 5d4959a
Switch Sheets persistence to per-user OAuth instead of a service account
Browse filesService accounts have zero Drive storage quota, so they can never
create a new file (even in a folder shared with them) -- confirmed by
testing the raw Drive API directly. Instead, request the drive.file
scope during Google login and expose the resulting access token via
Streamlit's expose_tokens=["access"], so each user's own account
creates and owns their spreadsheet directly. Drops the service
account, shared folder, and index-spreadsheet machinery entirely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- README.md +28 -26
- pages/points_tracker.py +6 -1
- src/bootstrap_secrets.py +7 -17
- src/sheets_backend.py +15 -35
README.md
CHANGED
|
@@ -23,21 +23,30 @@ forums](https://discuss.streamlit.io).
|
|
| 23 |
|
| 24 |
The app works fully anonymously with no setup. Logging in with Google (button
|
| 25 |
in the sidebar) additionally persists your players and round history to a
|
| 26 |
-
Google Sheet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
### One-time Google Cloud setup
|
| 29 |
-
1.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
from its URL. This becomes `shared_folder_id` below.
|
| 38 |
-
4. Run the one-time index-spreadsheet creation (see `src/sheets_backend.py`,
|
| 39 |
-
`get_or_create_user_workbook` needs an index spreadsheet — create one via
|
| 40 |
-
the service account inside the shared folder and note its ID).
|
| 41 |
|
| 42 |
### Local dev — `.streamlit/secrets.toml` (gitignored, never commit this)
|
| 43 |
```toml
|
|
@@ -47,23 +56,16 @@ cookie_secret = "<random string>"
|
|
| 47 |
client_id = "<oauth client id>"
|
| 48 |
client_secret = "<oauth client secret>"
|
| 49 |
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
|
|
|
|
| 50 |
|
| 51 |
-
[
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
[sheets]
|
| 55 |
-
index_spreadsheet_id = "<id of the index spreadsheet>"
|
| 56 |
-
shared_folder_id = "<id of the Drive folder shared with the service account>"
|
| 57 |
```
|
| 58 |
|
| 59 |
### Hugging Face Space deploy
|
| 60 |
`st.login()` only reads `st.secrets`, so `src/bootstrap_secrets.py` assembles
|
| 61 |
`.streamlit/secrets.toml` at startup from individual Space secrets (Settings →
|
| 62 |
Variables and secrets), so nothing sensitive needs to live in the repo or the
|
| 63 |
-
image:
|
| 64 |
-
|
| 65 |
-
`AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_COOKIE_SECRET`,
|
| 66 |
-
`AUTH_REDIRECT_URI` (your Space's public URL), `GCP_PROJECT_ID`,
|
| 67 |
-
`GCP_PRIVATE_KEY_ID`, `GCP_PRIVATE_KEY` (paste with literal `\n` for
|
| 68 |
-
newlines), `GCP_CLIENT_EMAIL`, `GCP_CLIENT_ID`, `GCP_CLIENT_X509_CERT_URL`,
|
| 69 |
-
`SHEETS_INDEX_ID`, `SHEETS_SHARED_FOLDER_ID`.
|
|
|
|
| 23 |
|
| 24 |
The app works fully anonymously with no setup. Logging in with Google (button
|
| 25 |
in the sidebar) additionally persists your players and round history to a
|
| 26 |
+
Google Sheet -- created directly in **your own Drive**, owned by you.
|
| 27 |
+
|
| 28 |
+
There's no service account and no shared storage involved: Streamlit's login
|
| 29 |
+
requests the `drive.file` scope alongside identity, so the app gets a
|
| 30 |
+
short-lived OAuth token that lets it create/edit only the one spreadsheet it
|
| 31 |
+
creates for you (`BoardGameTracker - {your email}`), nothing else in your
|
| 32 |
+
Drive. Because it's created by your own account, it uses your own storage --
|
| 33 |
+
there's no "service account has 0 storage quota" issue to work around.
|
| 34 |
+
|
| 35 |
+
Trade-off: since the app stays in Google's "Testing" publishing status
|
| 36 |
+
(no Google security review needed for a small friend group), each person who
|
| 37 |
+
wants to log in must first be added as a **test user** in the OAuth consent
|
| 38 |
+
screen, and will see an "unverified app" warning the first time they
|
| 39 |
+
click through.
|
| 40 |
|
| 41 |
### One-time Google Cloud setup
|
| 42 |
+
1. Create an OAuth 2.0 **Web application** Client ID; add your app's URL(s)
|
| 43 |
+
as authorized redirect URIs (e.g. `http://localhost:8501` for local dev).
|
| 44 |
+
2. On the OAuth consent screen, add every player's Google account email
|
| 45 |
+
under **Test users** (keep publishing status as "Testing" -- this avoids
|
| 46 |
+
Google's verification review entirely for `drive.file`, a scope that
|
| 47 |
+
only grants access to files the app itself creates).
|
| 48 |
+
3. No service account, no Sheets/Drive API project-level enablement, no
|
| 49 |
+
index spreadsheet -- the app's own OAuth client handles everything.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
### Local dev — `.streamlit/secrets.toml` (gitignored, never commit this)
|
| 52 |
```toml
|
|
|
|
| 56 |
client_id = "<oauth client id>"
|
| 57 |
client_secret = "<oauth client secret>"
|
| 58 |
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
|
| 59 |
+
expose_tokens = ["access"]
|
| 60 |
|
| 61 |
+
[auth.client_kwargs]
|
| 62 |
+
scope = "openid email profile https://www.googleapis.com/auth/drive.file"
|
| 63 |
+
prompt = "consent select_account"
|
|
|
|
|
|
|
|
|
|
| 64 |
```
|
| 65 |
|
| 66 |
### Hugging Face Space deploy
|
| 67 |
`st.login()` only reads `st.secrets`, so `src/bootstrap_secrets.py` assembles
|
| 68 |
`.streamlit/secrets.toml` at startup from individual Space secrets (Settings →
|
| 69 |
Variables and secrets), so nothing sensitive needs to live in the repo or the
|
| 70 |
+
image: `AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_COOKIE_SECRET`,
|
| 71 |
+
`AUTH_REDIRECT_URI` (your Space's public URL).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pages/points_tracker.py
CHANGED
|
@@ -26,8 +26,13 @@ def _get_workbook():
|
|
| 26 |
"""Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
|
| 27 |
if "auth" not in st.secrets or not st.user.is_logged_in:
|
| 28 |
return None
|
|
|
|
|
|
|
|
|
|
| 29 |
if "pt_workbook" not in st.session_state:
|
| 30 |
-
st.session_state.pt_workbook = sheets.get_or_create_user_workbook(
|
|
|
|
|
|
|
| 31 |
return st.session_state.pt_workbook
|
| 32 |
|
| 33 |
|
|
|
|
| 26 |
"""Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
|
| 27 |
if "auth" not in st.secrets or not st.user.is_logged_in:
|
| 28 |
return None
|
| 29 |
+
access_token = st.user.tokens.get("access")
|
| 30 |
+
if not access_token:
|
| 31 |
+
return None
|
| 32 |
if "pt_workbook" not in st.session_state:
|
| 33 |
+
st.session_state.pt_workbook = sheets.get_or_create_user_workbook(
|
| 34 |
+
access_token, st.user.email
|
| 35 |
+
)
|
| 36 |
return st.session_state.pt_workbook
|
| 37 |
|
| 38 |
|
src/bootstrap_secrets.py
CHANGED
|
@@ -5,6 +5,8 @@ import tomli_w
|
|
| 5 |
|
| 6 |
_SECRETS_PATH = Path(".streamlit/secrets.toml")
|
| 7 |
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def ensure_secrets_file() -> None:
|
| 10 |
if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
|
|
@@ -18,23 +20,11 @@ def ensure_secrets_file() -> None:
|
|
| 18 |
"client_id": os.environ["AUTH_CLIENT_ID"],
|
| 19 |
"client_secret": os.environ["AUTH_CLIENT_SECRET"],
|
| 20 |
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
"private_key": os.environ["GCP_PRIVATE_KEY"].replace("\\n", "\n"),
|
| 27 |
-
"client_email": os.environ["GCP_CLIENT_EMAIL"],
|
| 28 |
-
"client_id": os.environ["GCP_CLIENT_ID"],
|
| 29 |
-
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 30 |
-
"token_uri": "https://oauth2.googleapis.com/token",
|
| 31 |
-
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 32 |
-
"client_x509_cert_url": os.environ["GCP_CLIENT_X509_CERT_URL"],
|
| 33 |
-
"universe_domain": "googleapis.com",
|
| 34 |
-
},
|
| 35 |
-
"sheets": {
|
| 36 |
-
"index_spreadsheet_id": os.environ["SHEETS_INDEX_ID"],
|
| 37 |
-
"shared_folder_id": os.environ.get("SHEETS_SHARED_FOLDER_ID", ""),
|
| 38 |
},
|
| 39 |
}
|
| 40 |
_SECRETS_PATH.write_text(tomli_w.dumps(data))
|
|
|
|
| 5 |
|
| 6 |
_SECRETS_PATH = Path(".streamlit/secrets.toml")
|
| 7 |
|
| 8 |
+
_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"
|
| 9 |
+
|
| 10 |
|
| 11 |
def ensure_secrets_file() -> None:
|
| 12 |
if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
|
|
|
|
| 20 |
"client_id": os.environ["AUTH_CLIENT_ID"],
|
| 21 |
"client_secret": os.environ["AUTH_CLIENT_SECRET"],
|
| 22 |
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
| 23 |
+
"expose_tokens": ["access"],
|
| 24 |
+
"client_kwargs": {
|
| 25 |
+
"scope": f"openid email profile {_DRIVE_FILE_SCOPE}",
|
| 26 |
+
"prompt": "consent select_account",
|
| 27 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
}
|
| 30 |
_SECRETS_PATH.write_text(tomli_w.dumps(data))
|
src/sheets_backend.py
CHANGED
|
@@ -2,57 +2,37 @@ import uuid
|
|
| 2 |
from datetime import datetime, timezone
|
| 3 |
|
| 4 |
import gspread
|
| 5 |
-
|
| 6 |
-
from google.oauth2.service_account import Credentials
|
| 7 |
-
|
| 8 |
-
_SCOPES = [
|
| 9 |
-
"https://www.googleapis.com/auth/spreadsheets",
|
| 10 |
-
"https://www.googleapis.com/auth/drive",
|
| 11 |
-
]
|
| 12 |
|
| 13 |
_PLAYERS_HEADER = ["player_id", "name", "created_at"]
|
| 14 |
_GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
creds = Credentials.from_service_account_info(
|
| 20 |
-
st.secrets["gcp_service_account"], scopes=_SCOPES
|
| 21 |
-
)
|
| 22 |
return gspread.authorize(creds)
|
| 23 |
|
| 24 |
|
| 25 |
-
def
|
| 26 |
-
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
client = _get_client()
|
| 37 |
-
index_ws = _get_index_ws()
|
| 38 |
-
rows = index_ws.get_all_records()
|
| 39 |
-
for row in rows:
|
| 40 |
-
if row.get("email") == email:
|
| 41 |
-
return client.open_by_key(row["spreadsheet_id"])
|
| 42 |
-
|
| 43 |
-
sh = client.create(f"BoardGameTracker - {email}", folder_id=_folder_id())
|
| 44 |
players_ws = sh.sheet1
|
| 45 |
players_ws.update_title("players")
|
| 46 |
players_ws.append_row(_PLAYERS_HEADER)
|
| 47 |
games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
|
| 48 |
games_ws.append_row(_GAMES_HEADER)
|
| 49 |
-
|
| 50 |
-
try:
|
| 51 |
-
sh.share(email, perm_type="user", role="writer")
|
| 52 |
-
except gspread.exceptions.APIError:
|
| 53 |
-
pass # sharing is a nice-to-have; don't block provisioning on it
|
| 54 |
-
|
| 55 |
-
index_ws.append_row([email, sh.id, sh.url, datetime.now(timezone.utc).isoformat()])
|
| 56 |
return sh
|
| 57 |
|
| 58 |
|
|
|
|
| 2 |
from datetime import datetime, timezone
|
| 3 |
|
| 4 |
import gspread
|
| 5 |
+
from google.oauth2.credentials import Credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
_PLAYERS_HEADER = ["player_id", "name", "created_at"]
|
| 8 |
_GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
|
| 9 |
+
_WORKBOOK_TITLE_PREFIX = "BoardGameTracker"
|
| 10 |
|
| 11 |
|
| 12 |
+
def _get_user_client(access_token: str) -> gspread.Client:
|
| 13 |
+
creds = Credentials(token=access_token)
|
|
|
|
|
|
|
|
|
|
| 14 |
return gspread.authorize(creds)
|
| 15 |
|
| 16 |
|
| 17 |
+
def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet:
|
| 18 |
+
"""Returns the user's own Sheets workbook, creating it in their Drive on first use.
|
|
|
|
| 19 |
|
| 20 |
+
Uses the user's own OAuth access token (drive.file scope), so the file is
|
| 21 |
+
owned by them directly -- no service account or shared storage involved.
|
| 22 |
+
"""
|
| 23 |
+
client = _get_user_client(access_token)
|
| 24 |
+
title = f"{_WORKBOOK_TITLE_PREFIX} - {email}"
|
| 25 |
|
| 26 |
+
existing = client.list_spreadsheet_files(title=title)
|
| 27 |
+
if existing:
|
| 28 |
+
return client.open_by_key(existing[0]["id"])
|
| 29 |
|
| 30 |
+
sh = client.create(title)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
players_ws = sh.sheet1
|
| 32 |
players_ws.update_title("players")
|
| 33 |
players_ws.append_row(_PLAYERS_HEADER)
|
| 34 |
games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
|
| 35 |
games_ws.append_row(_GAMES_HEADER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return sh
|
| 37 |
|
| 38 |
|