diff --git a/app/api/schwab_auth.py b/app/api/schwab_auth.py new file mode 100644 index 0000000..5912780 --- /dev/null +++ b/app/api/schwab_auth.py @@ -0,0 +1,72 @@ +"""Schwab OAuth callback. + +Schwab requires an HTTPS callback URL. The usual answer is +``https://127.0.0.1:8182`` with a self-signed certificate, which means clicking +through a browser warning on every re-authentication — and the refresh token +expires weekly. There are also reports of Schwab refusing to register apps whose +callback is a loopback address. + +This app already terminates real HTTPS, so it can receive the redirect itself. + +Deliberately unauthenticated: Schwab redirects a browser here and cannot attach +the chart token. Nothing is stored — the page only echoes the query string of +the request that produced it, which the caller already has in their address bar. +Storing the code would mean a later, unauthenticated visitor could read it. + +The path is deliberately unrevealing. That is not a security control — the +endpoint's safety is that it is inert — it simply avoids advertising which +brokerage this host talks to. Treat it as fixed: changing a registered callback +URL means editing the Schwab app, which can send it back through approval. +""" +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +router = APIRouter(prefix="/api") + +PAGE = """ + +
Paste this entire URL into the waiting login prompt:
+ +Single use, and it expires within minutes. Do not share it.
+Nothing was stored on the server. This page shows only the URL +you just arrived with.
+""" + +IDLE = """ +OAuth callback endpoint. Register this exact URL with the provider:
+{url}
Arriving here directly is expected and harmless — the useful +version of this page is the one you are redirected to.
+""" + + +@router.get("/qt", response_class=HTMLResponse) +def callback(request: Request) -> HTMLResponse: + if request.query_params.get("code"): + page = PAGE.format( + heading="Authorisation received", + body=RECEIVED.format(url=str(request.url)), + ) + else: + page = PAGE.format( + heading="Callback endpoint", + body=IDLE.format(url=str(request.url).split("?")[0]), + ) + # Never cached: it carries a single-use authorisation code. + return HTMLResponse(page, headers={"Cache-Control": "no-store"}) diff --git a/main.py b/main.py index 44b1445..d8b818b 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from fastapi.staticfiles import StaticFiles from app.api.meta import router as meta_router from app.api.routes import router as api_router +from app.api.schwab_auth import router as schwab_auth_router from app.api.ws import router as ws_router from app.config import Settings from app.runtime import Runtime @@ -37,6 +38,7 @@ app = FastAPI(title="chart", lifespan=lifespan) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.include_router(meta_router) +app.include_router(schwab_auth_router) app.include_router(api_router) app.include_router(ws_router) diff --git a/tests/test_schwab_callback.py b/tests/test_schwab_callback.py new file mode 100644 index 0000000..268050d --- /dev/null +++ b/tests/test_schwab_callback.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.schwab_auth import router + + +def client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_callback_needs_no_chart_token(): + # Schwab redirects a browser here and cannot attach the token, so this + # endpoint has to stay open the way /health and /version do. + assert client().get("/api/qt").status_code == 200 + + +def test_page_does_not_name_the_brokerage(): + # The path is neutral so the host does not advertise who it trades with; + # the page saying it anyway would defeat that. + assert "chwab" not in client().get("/api/qt").text + + +def test_landing_here_directly_explains_itself(): + body = client().get("/api/qt").text + assert "Register this exact URL" in body + assert "code" not in body.split("