Skip to content

Commit 4581411

Browse files
ryanbarlow97claude
andauthored
Let branch previews sign in with Discord through dev (#154)
* Let branch previews sign in with Discord through dev Discord only returns players to registered callbacks, so a preview at <slug>.tfminecraft.net could not sign anyone in. Previews now send the browser to dev, which signs the player in (or uses its existing session) and returns them with a single-use, 120-second ticket. The preview redeems it with dev for the Discord identity, website role and guild check, then opens its own session. Previews hold no Discord secret. Dev needs PREVIEW_SIGN_IN_DOMAIN=tfminecraft.net; preview images get SIGN_IN_SITE=https://dev.tfminecraft.net at build time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Close the gaps Astra found in preview sign-in - Drop preview start and callback queries from access logs, so a logged ticket cannot be redeemed by someone reading the logs. - When dev's bot cannot refresh a guild check over 15 minutes old, sign in with Discord again instead of handing the preview a stale check that linking would refuse, in a loop. - Refuse malformed preview origins instead of failing with a 500. - Say in the Preview summary that sign-in goes through dev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Get a fresh membership answer when a preview asks to check again "I've joined, check again" on a preview signs in through dev again. With dev's bot unable to answer, dev handed back the recent "not a member" check, so the button did nothing for up to 15 minutes. A preview that already has a session now asks dev for a recheck, and dev then signs in with Discord once (the saved return drops the recheck, so it ends). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 72998eb commit 4581411

10 files changed

Lines changed: 754 additions & 17 deletions

File tree

‎.github/workflows/preview.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ jobs:
5757
push: ${{ env.IS_MAIN != 'true' }}
5858
provenance: false
5959
# The browser calls the preview's own /api, which the router sends to its backend.
60-
build-args: ${{ matrix.service == 'frontend' && format('NEXT_PUBLIC_API_URL={0}/api', steps.slug.outputs.url) || '' }}
60+
# The backend signs players in through dev, as Discord only returns them to registered sites.
61+
build-args: ${{ matrix.service == 'frontend' && format('NEXT_PUBLIC_API_URL={0}/api', steps.slug.outputs.url) || 'SIGN_IN_SITE=https://dev.tfminecraft.net' }}
6162
tags: ${{ env.IS_MAIN != 'true' && format('{0}-{1}:{2}', env.IMAGE_PREFIX, matrix.service, steps.slug.outputs.slug) || '' }}
6263
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
6364
# Branches cannot read each other's Actions cache, so main keeps a registry cache they all share.
@@ -100,7 +101,7 @@ jobs:
100101
echo "### Preview: $URL"
101102
echo
102103
echo "Built from \`${GITHUB_SHA::7}\`. It stops 90 minutes after this deploy; push again or re-run this workflow to restart it."
103-
echo "Discord sign-in is off on previews."
104+
echo "Discord sign-in goes through dev and keeps your dev role."
104105
} >> "$GITHUB_STEP_SUMMARY"
105106
106107
teardown:

‎backend/Dockerfile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ RUN npm run build
4444

4545
WORKDIR /app
4646

47+
# Previews sign in through dev (src/auth/preview_sign_in.py); empty everywhere else.
48+
ARG SIGN_IN_SITE=""
49+
ENV SIGN_IN_SITE=${SIGN_IN_SITE}
50+
4751
EXPOSE 8000
4852

4953
CMD ["python3", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]

‎backend/server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@
2626

2727

2828
class _OAuthQueryFilter(logging.Filter):
29-
"""Drop OAuth codes and states from access log lines for callback routes."""
29+
"""Drop OAuth codes, states and preview tickets from access log lines for sign-in routes."""
3030

31-
PATHS = ("/auth/discord/callback", "/auth/microsoft/callback", "/patreon/oauth/callback")
31+
PATHS = (
32+
"/auth/discord/callback", "/auth/microsoft/callback", "/patreon/oauth/callback",
33+
"/auth/preview/start", "/auth/preview/callback",
34+
)
3235

3336
def filter(self, record: logging.LogRecord) -> bool:
3437
args = record.args

‎backend/src/api/auth_routes.py‎

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import os
1212
import re
13+
import secrets
1314
import time
1415
from collections import defaultdict
1516
from urllib.parse import urlencode, urlsplit
@@ -19,7 +20,7 @@
1920
from pydantic import BaseModel, Field
2021

2122
from src.api.prod_guard import is_production
22-
from src.auth import account_overview, guild_check, users
23+
from src.auth import account_overview, guild_check, preview_sign_in, users
2324
from src.auth.config import AuthConfig
2425
from src.auth import microsoft_link
2526
from src.auth.discord import DiscordClient, DiscordError
@@ -71,7 +72,7 @@ def build_microsoft_client(config: MicrosoftConfig) -> MicrosoftClient:
7172

7273
def _config() -> AuthConfig:
7374
config = AuthConfig.from_env()
74-
if not config.enabled or config.problems():
75+
if not (config.enabled or config.via_site) or config.problems():
7576
raise HTTPException(503, detail="discord_auth_disabled")
7677
return config
7778

@@ -161,8 +162,10 @@ def _patreon(user: dict) -> dict | None:
161162
# --------------------
162163

163164
@auth_router.get("/auth/discord/start")
164-
def discord_start(return_to: str | None = None):
165+
def discord_start(request: Request, return_to: str | None = None):
165166
config = _config()
167+
if config.via_site:
168+
return _preview_start(request, config, return_to)
166169
state, url = users.start_sign_in(config, return_to)
167170
response = _no_store(RedirectResponse(url, status_code=302))
168171
_set_cookie(response, config, state_cookie(config), state, int(users.STATE_TTL.total_seconds()))
@@ -172,6 +175,8 @@ def discord_start(return_to: str | None = None):
172175
@auth_router.get("/auth/discord/callback")
173176
def discord_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
174177
config = _config()
178+
if config.via_site:
179+
raise HTTPException(404, detail="Not Found")
175180
cookie_state = request.cookies.get(state_cookie(config)) or ""
176181
status, return_to, token = "error", users.RETURN_DEFAULT, None
177182
if state and cookie_state and hmac.compare_digest(cookie_state, state):
@@ -223,6 +228,115 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None:
223228
return users.sign_in(identity, guild_member=guild_member, member=member)
224229

225230

231+
# --------------------
232+
# Branch previews sign in through dev (src/auth/preview_sign_in.py)
233+
# --------------------
234+
235+
PREVIEW_START_PATH = "/api/auth/preview/start"
236+
PREVIEW_CALLBACK_PATH = "/api/auth/preview/callback"
237+
238+
239+
def _preview_start(request: Request, config: AuthConfig, return_to: str | None) -> Response:
240+
"""On a preview: send the browser to the sign-in site, which returns it with a ticket.
241+
242+
Signing in again while signed in is "I've joined, check again", so dev must not hand
243+
back the check it already has.
244+
"""
245+
state = secrets.token_urlsafe(32)
246+
params = {"site": config.site_origin, "state": state, "return_to": users.clean_return_to(return_to)}
247+
if users.session_user(request.cookies.get(session_cookie(config))) is not None:
248+
params["recheck"] = "1"
249+
query = urlencode(params)
250+
response = _no_store(RedirectResponse(f"{config.sign_in_site}{PREVIEW_START_PATH}?{query}", status_code=302))
251+
_set_cookie(response, config, state_cookie(config), state, int(users.STATE_TTL.total_seconds()))
252+
return response
253+
254+
255+
@auth_router.get("/auth/preview/start")
256+
def preview_start(
257+
request: Request,
258+
site: str | None = None,
259+
state: str | None = None,
260+
return_to: str | None = None,
261+
recheck: str | None = None,
262+
):
263+
"""On dev: hand the signed-in player to a preview, signing them in here first if needed."""
264+
config = _config()
265+
target = preview_sign_in.preview_site(site)
266+
if config.via_site or target is None or not preview_sign_in.valid_token(state):
267+
raise HTTPException(404, detail="Not Found")
268+
back = users.clean_return_to(return_to)
269+
# Discord's callback returns the browser here, now signed in and freshly checked.
270+
again = PREVIEW_START_PATH + "?" + urlencode({"site": target, "state": state, "return_to": back})
271+
if users.clean_return_to(again) != again:
272+
again = PREVIEW_START_PATH + "?" + urlencode({"site": target, "state": state})
273+
user = users.session_user(request.cookies.get(session_cookie(config)))
274+
if user is None:
275+
return discord_start(request, again)
276+
# Linking on the preview needs a recent check, and only dev can make one: with the
277+
# bot, or else by signing in again. `again` drops recheck, so this ends.
278+
user, answered = guild_check.check(user)
279+
if not answered and (recheck == "1" or not users.guild_check_recent(user)):
280+
return discord_start(request, again)
281+
ticket = preview_sign_in.issue(target, user)
282+
logger.info("Preview sign-in ticket user_id=%s site=%s", user["user_id"], target)
283+
query = urlencode({"ticket": ticket, "state": state, "return_to": back})
284+
response = _no_store(RedirectResponse(f"{target}{PREVIEW_CALLBACK_PATH}?{query}", status_code=302))
285+
response.headers["Referrer-Policy"] = "no-referrer"
286+
return response
287+
288+
289+
class RedeemBody(BaseModel):
290+
ticket: str = Field(..., max_length=256)
291+
site: str = Field(..., max_length=256)
292+
293+
294+
@auth_router.post("/auth/preview/redeem")
295+
def preview_redeem(body: RedeemBody, response: Response):
296+
"""On dev: the preview's backend trades a ticket for the player it carries."""
297+
config = _config()
298+
site = preview_sign_in.preview_site(body.site)
299+
if config.via_site or site is None:
300+
raise HTTPException(404, detail="Not Found")
301+
player = preview_sign_in.redeem(body.ticket, site)
302+
if player is None:
303+
raise HTTPException(410, detail="ticket_invalid")
304+
_no_store(response)
305+
return player
306+
307+
308+
@auth_router.get("/auth/preview/callback")
309+
def preview_callback(
310+
request: Request, ticket: str | None = None, state: str | None = None, return_to: str | None = None
311+
):
312+
"""On a preview: open a session for the player dev's ticket carries."""
313+
config = _config()
314+
if not config.via_site:
315+
raise HTTPException(404, detail="Not Found")
316+
cookie_state = request.cookies.get(state_cookie(config)) or ""
317+
status, token = "expired", None
318+
if (
319+
preview_sign_in.valid_token(state)
320+
and preview_sign_in.valid_token(cookie_state)
321+
and hmac.compare_digest(cookie_state, state)
322+
and preview_sign_in.valid_token(ticket)
323+
):
324+
player = preview_sign_in.fetch_player(config, ticket)
325+
token = preview_sign_in.sign_in(player) if player else None
326+
status = "ok" if token else "error"
327+
logger.info("Preview sign-in callback status=%s", status)
328+
response = (
329+
_site_redirect(config, users.clean_return_to(return_to)) if token
330+
else _site_redirect(config, users.RETURN_DEFAULT, signin=status)
331+
)
332+
# The ticket is in this URL; keep it out of the next page's Referer.
333+
response.headers["Referrer-Policy"] = "no-referrer"
334+
_clear_cookie(response, config, state_cookie(config))
335+
if token:
336+
_set_cookie(response, config, session_cookie(config), token, int(users.SESSION_TTL.total_seconds()))
337+
return response
338+
339+
226340
@auth_router.post("/auth/logout")
227341
def logout(request: Request):
228342
config = _config()

‎backend/src/auth/config.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class AuthConfig:
3030
guild_id: str
3131
site_url: str
3232
api_base: str
33+
# A branch preview signs in through this site instead of with Discord; see preview_sign_in.py.
34+
sign_in_site: str = ""
3335

3436
@classmethod
3537
def from_env(cls) -> AuthConfig:
@@ -44,8 +46,14 @@ def from_env(cls) -> AuthConfig:
4446
guild_id=os.getenv("DISCORD_GUILD_ID", "").strip(),
4547
site_url=site,
4648
api_base=(os.getenv("DISCORD_API_BASE", API_BASE_DEFAULT).strip() or API_BASE_DEFAULT).rstrip("/"),
49+
sign_in_site=os.getenv("SIGN_IN_SITE", "").strip().rstrip("/"),
4750
)
4851

52+
@property
53+
def via_site(self) -> bool:
54+
"""True on a branch preview, which has no Discord settings of its own."""
55+
return bool(self.sign_in_site)
56+
4957
@property
5058
def site_origin(self) -> str:
5159
# problems() refuses an invalid site, so routes never see this fallback.
@@ -59,15 +67,19 @@ def secure_cookies(self) -> bool:
5967
def problems(self) -> list[str]:
6068
"""Settings that stop sign-in from working; empty when usable."""
6169
errors = []
70+
site = _origin(self.site_url)
71+
if site is None or urlsplit(self.site_url).path not in {"", "/"}:
72+
errors.append("SITE_PUBLIC_URL")
73+
if self.via_site:
74+
if _origin(self.sign_in_site) != self.sign_in_site or self.sign_in_site == site:
75+
errors.append("SIGN_IN_SITE")
76+
return errors
6277
if not self.client_id or any(ch.isspace() for ch in self.client_id):
6378
errors.append("DISCORD_CLIENT_ID")
6479
if not self.client_secret:
6580
errors.append("DISCORD_CLIENT_SECRET")
6681
if not self.guild_id.isdigit() or len(self.guild_id) > SNOWFLAKE_MAX_LEN:
6782
errors.append("DISCORD_GUILD_ID")
68-
site = _origin(self.site_url)
69-
if site is None or urlsplit(self.site_url).path not in {"", "/"}:
70-
errors.append("SITE_PUBLIC_URL")
7183
if not _callback_matches_site(self.redirect_uri, site) or not urlsplit(self.redirect_uri).path.endswith(
7284
CALLBACK_ROUTE
7385
):

0 commit comments

Comments
 (0)