"""GitHub OAuth proxy for Decap CMS."""
from __future__ import annotations
import os
from pathlib import Path
import httpx
from litestar import Litestar, get
from litestar.openapi.config import OpenAPIConfig
from litestar.openapi.plugins import ScalarRenderPlugin
from litestar.openapi.spec import Contact, ExternalDocumentation, License, Server
from litestar.response import Redirect
from litestar.response.base import ASGIResponse
# Load .env from oauth/ or repo root if present
_env_file = Path(__file__).parent / ".env"
if not _env_file.is_file():
_env_file = Path(__file__).parent.parent / ".env"
if _env_file.is_file():
for line in _env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
TOKEN_URL = "https://github.com/login/oauth/access_token"
CALLBACK_HTML = """<!doctype html>
<html><body><script>
(function() {
const token = "%s";
const data = JSON.stringify({token: token, provider: "github"});
const msg = "authorization:github:success:" + data;
function sendToken(origin) {
window.opener.postMessage(msg, origin || "*");
window.close();
}
// Listen for "authorizing:github" from the parent (Sveltia CMS protocol)
window.addEventListener("message", function(e) {
if (e.data === "authorizing:github") {
sendToken(e.origin);
}
}, false);
// Also notify the parent we're ready (Decap CMS protocol),
// which triggers it to send "authorizing:github" back to us.
window.opener.postMessage("authorizing:github", "*");
})();
</script></body></html>
"""
[docs]
@get("/_health/")
async def health() -> dict[str, str]:
"""Health check endpoint for load balancers and uptime monitors.
Returns ``{"status": "ok"}`` when the service is running.
"""
return {"status": "ok"}
[docs]
@get("/auth")
async def auth(scope: str = "public_repo", provider: str = "github", site_id: str = "") -> Redirect:
"""Redirect the user to GitHub's OAuth authorization page.
Decap CMS hits this endpoint to start the OAuth flow. The user gets
sent to GitHub to approve access, then GitHub redirects back to
:func:`callback` with an authorization code.
:param scope: GitHub OAuth scope to request. Defaults to ``public_repo``.
:param provider: OAuth provider name (passed by Decap CMS, always ``github``).
:param site_id: Site identifier (passed by Decap CMS, unused).
"""
return Redirect(f"{AUTHORIZE_URL}?client_id={CLIENT_ID}&scope={scope}")
[docs]
@get("/callback", media_type="text/html")
async def callback(code: str) -> ASGIResponse:
"""Exchange a GitHub authorization code for an access token.
GitHub redirects here after the user approves the OAuth request.
The proxy exchanges the temporary *code* for a long-lived access token,
then returns a small HTML page that ``postMessage``'s the token back to
the Decap CMS window.
:param code: The authorization code from GitHub's OAuth redirect.
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
json={"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code": code},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
body = resp.json()
token = body.get("access_token")
if not token:
error = body.get("error_description", body.get("error", "unknown error"))
return ASGIResponse(
body=f"OAuth token exchange failed: {error}".encode(),
media_type="text/plain",
status_code=400,
)
return ASGIResponse(body=(CALLBACK_HTML % token).encode(), media_type="text/html")
app = Litestar(
route_handlers=[health, auth, callback],
openapi_config=OpenAPIConfig(
title="Python Wiki API",
version="1.0.0",
summary="GitHub OAuth proxy powering Decap CMS for the Python Wiki.",
description=(
"Provides the OAuth handshake between Decap CMS (running in the browser) "
"and GitHub, so editors can authenticate and commit changes to the wiki "
"repository without exposing client secrets.\n\n"
"## Endpoints\n\n"
"| Path | Purpose |\n"
"|---|---|\n"
"| `GET /auth` | Redirects the user to GitHub's OAuth authorize page |\n"
"| `GET /callback` | Exchanges the authorization code for an access token "
"and posts it back to Decap CMS via `postMessage` |\n"
"| `GET /_health/` | Health check for load balancers and uptime monitors |\n"
),
contact=Contact(
name="Python Software Foundation",
url="https://www.python.org/psf/",
email="psf@python.org",
),
license=License(name="MIT", identifier="MIT"),
external_docs=ExternalDocumentation(
description="Python Wiki source repository",
url="https://github.com/python/wiki",
),
servers=[
Server(url="https://api.wiki.python.org", description="Production"),
Server(url="http://localhost:8000", description="Local development"),
],
render_plugins=[ScalarRenderPlugin(path="/")],
path="/api",
),
)