Add collection OAuth scope (#226)
Browse files* Add collection OAuth scope
Co-authored-by: OpenAI Codex <codex@openai.com>
* Document GitHub PR-first workflow
Co-authored-by: OpenAI Codex <codex@openai.com>
* Document Space OAuth scope checks
Co-authored-by: OpenAI Codex <codex@openai.com>
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
- AGENTS.md +5 -0
- backend/routes/auth.py +13 -1
- tests/unit/test_auth_token_propagation.py +14 -0
AGENTS.md
CHANGED
|
@@ -24,11 +24,16 @@ Notes:
|
|
| 24 |
|
| 25 |
- For multiline PR descriptions, prefer `gh pr edit <number> --body-file <file>` over inline `--body` so shell quoting, `$` env-var names, backticks, and newlines are preserved correctly.
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
## Hugging Face Space Deploys
|
| 28 |
|
| 29 |
- The Space remote is `space` and points to `https://huggingface.co/spaces/smolagents/ml-intern`.
|
| 30 |
- Deploy GitHub `main` to the Space from the local `space-main` branch by merging `origin/main` into `space-main` with a single merge commit, then pushing `space-main:main` to the `space` remote.
|
| 31 |
- Keep the Space-only README frontmatter on `space-main`; `.gitattributes` should contain `README.md merge=ours` and the local repo config should include `merge.ours.driver=true`.
|
|
|
|
| 32 |
- Recommended deploy flow:
|
| 33 |
|
| 34 |
```bash
|
|
|
|
| 24 |
|
| 25 |
- For multiline PR descriptions, prefer `gh pr edit <number> --body-file <file>` over inline `--body` so shell quoting, `$` env-var names, backticks, and newlines are preserved correctly.
|
| 26 |
|
| 27 |
+
## GitHub PRs
|
| 28 |
+
|
| 29 |
+
- Open code changes as GitHub PRs first. Do not push code changes directly to the Hugging Face Space deployment branch or Space remote before the PR has been opened, reviewed, and merged, unless the user explicitly asks to bypass the PR flow.
|
| 30 |
+
|
| 31 |
## Hugging Face Space Deploys
|
| 32 |
|
| 33 |
- The Space remote is `space` and points to `https://huggingface.co/spaces/smolagents/ml-intern`.
|
| 34 |
- Deploy GitHub `main` to the Space from the local `space-main` branch by merging `origin/main` into `space-main` with a single merge commit, then pushing `space-main:main` to the `space` remote.
|
| 35 |
- Keep the Space-only README frontmatter on `space-main`; `.gitattributes` should contain `README.md merge=ours` and the local repo config should include `merge.ours.driver=true`.
|
| 36 |
+
- Local dev commonly uses a personal `HF_TOKEN`, but the deployed Space uses HF OAuth tokens. When adding Hub features, make sure the Space README `hf_oauth_scopes` frontmatter and the backend OAuth request in `backend/routes/auth.py` include the scopes required by the Hub APIs being called. A feature can work locally with a broad PAT and still fail in production with 403s if OAuth scopes are missing; after changing scopes, users may need to log out and log in again to receive a fresh token.
|
| 37 |
- Recommended deploy flow:
|
| 38 |
|
| 39 |
```bash
|
backend/routes/auth.py
CHANGED
|
@@ -20,6 +20,18 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|
| 20 |
OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
| 21 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 22 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# In-memory OAuth state store with expiry (5 min TTL)
|
| 25 |
_OAUTH_STATE_TTL = 300
|
|
@@ -69,7 +81,7 @@ async def oauth_login(request: Request) -> RedirectResponse:
|
|
| 69 |
params = {
|
| 70 |
"client_id": OAUTH_CLIENT_ID,
|
| 71 |
"redirect_uri": get_redirect_uri(request),
|
| 72 |
-
"scope": "
|
| 73 |
"response_type": "code",
|
| 74 |
"state": state,
|
| 75 |
}
|
|
|
|
| 20 |
OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
| 21 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 22 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 23 |
+
OAUTH_SCOPES = (
|
| 24 |
+
"openid",
|
| 25 |
+
"profile",
|
| 26 |
+
"read-repos",
|
| 27 |
+
"write-repos",
|
| 28 |
+
"contribute-repos",
|
| 29 |
+
"manage-repos",
|
| 30 |
+
"write-collections",
|
| 31 |
+
"inference-api",
|
| 32 |
+
"jobs",
|
| 33 |
+
"write-discussions",
|
| 34 |
+
)
|
| 35 |
|
| 36 |
# In-memory OAuth state store with expiry (5 min TTL)
|
| 37 |
_OAUTH_STATE_TTL = 300
|
|
|
|
| 81 |
params = {
|
| 82 |
"client_id": OAUTH_CLIENT_ID,
|
| 83 |
"redirect_uri": get_redirect_uri(request),
|
| 84 |
+
"scope": " ".join(OAUTH_SCOPES),
|
| 85 |
"response_type": "code",
|
| 86 |
"state": state,
|
| 87 |
}
|
tests/unit/test_auth_token_propagation.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import sys
|
| 4 |
from pathlib import Path
|
| 5 |
from types import SimpleNamespace
|
|
|
|
| 6 |
|
| 7 |
import pytest
|
| 8 |
|
|
@@ -59,3 +60,16 @@ async def test_auth_me_does_not_expose_internal_hf_token():
|
|
| 59 |
"username": "alice",
|
| 60 |
"authenticated": True,
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import sys
|
| 4 |
from pathlib import Path
|
| 5 |
from types import SimpleNamespace
|
| 6 |
+
from urllib.parse import parse_qs, urlparse
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
|
|
|
| 60 |
"username": "alice",
|
| 61 |
"authenticated": True,
|
| 62 |
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.mark.asyncio
|
| 66 |
+
async def test_oauth_login_requests_collection_write_scope(monkeypatch):
|
| 67 |
+
monkeypatch.setattr(auth, "OAUTH_CLIENT_ID", "oauth-client")
|
| 68 |
+
monkeypatch.setenv("SPACE_HOST", "example.hf.space")
|
| 69 |
+
auth.oauth_states.clear()
|
| 70 |
+
|
| 71 |
+
response = await auth.oauth_login(SimpleNamespace())
|
| 72 |
+
params = parse_qs(urlparse(response.headers["location"]).query)
|
| 73 |
+
scopes = set(params["scope"][0].split())
|
| 74 |
+
|
| 75 |
+
assert "write-collections" in scopes
|