Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ jobs:
push: ${{ env.IS_MAIN != 'true' }}
provenance: false
# The browser calls the preview's own /api, which the router sends to its backend.
build-args: ${{ matrix.service == 'frontend' && format('NEXT_PUBLIC_API_URL={0}/api', steps.slug.outputs.url) || '' }}
# The backend signs players in through dev, as Discord only returns them to registered sites.
build-args: ${{ matrix.service == 'frontend' && format('NEXT_PUBLIC_API_URL={0}/api', steps.slug.outputs.url) || 'SIGN_IN_SITE=https://dev.tfminecraft.net' }}
tags: ${{ env.IS_MAIN != 'true' && format('{0}-{1}:{2}', env.IMAGE_PREFIX, matrix.service, steps.slug.outputs.slug) || '' }}
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
# Branches cannot read each other's Actions cache, so main keeps a registry cache they all share.
Expand Down Expand Up @@ -100,7 +101,7 @@ jobs:
echo "### Preview: $URL"
echo
echo "Built from \`${GITHUB_SHA::7}\`. It stops 90 minutes after this deploy; push again or re-run this workflow to restart it."
echo "Discord sign-in is off on previews."
echo "Discord sign-in goes through dev and keeps your dev role."
} >> "$GITHUB_STEP_SUMMARY"

teardown:
Expand Down
4 changes: 4 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ RUN npm run build

WORKDIR /app

# Previews sign in through dev (src/auth/preview_sign_in.py); empty everywhere else.
ARG SIGN_IN_SITE=""
ENV SIGN_IN_SITE=${SIGN_IN_SITE}

EXPOSE 8000

CMD ["python3", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
7 changes: 5 additions & 2 deletions backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@


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

PATHS = ("/auth/discord/callback", "/auth/microsoft/callback", "/patreon/oauth/callback")
PATHS = (
"/auth/discord/callback", "/auth/microsoft/callback", "/patreon/oauth/callback",
"/auth/preview/start", "/auth/preview/callback",
)

def filter(self, record: logging.LogRecord) -> bool:
args = record.args
Expand Down
120 changes: 117 additions & 3 deletions backend/src/api/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import os
import re
import secrets
import time
from collections import defaultdict
from urllib.parse import urlencode, urlsplit
Expand All @@ -19,7 +20,7 @@
from pydantic import BaseModel, Field

from src.api.prod_guard import is_production
from src.auth import account_overview, guild_check, users
from src.auth import account_overview, guild_check, preview_sign_in, users
from src.auth.config import AuthConfig
from src.auth import microsoft_link
from src.auth.discord import DiscordClient, DiscordError
Expand Down Expand Up @@ -71,7 +72,7 @@ def build_microsoft_client(config: MicrosoftConfig) -> MicrosoftClient:

def _config() -> AuthConfig:
config = AuthConfig.from_env()
if not config.enabled or config.problems():
if not (config.enabled or config.via_site) or config.problems():
raise HTTPException(503, detail="discord_auth_disabled")
return config

Expand Down Expand Up @@ -161,8 +162,10 @@ def _patreon(user: dict) -> dict | None:
# --------------------

@auth_router.get("/auth/discord/start")
def discord_start(return_to: str | None = None):
def discord_start(request: Request, return_to: str | None = None):
config = _config()
if config.via_site:
return _preview_start(request, config, return_to)
state, url = users.start_sign_in(config, return_to)
response = _no_store(RedirectResponse(url, status_code=302))
_set_cookie(response, config, state_cookie(config), state, int(users.STATE_TTL.total_seconds()))
Expand All @@ -172,6 +175,8 @@ def discord_start(return_to: str | None = None):
@auth_router.get("/auth/discord/callback")
def discord_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
config = _config()
if config.via_site:
raise HTTPException(404, detail="Not Found")
cookie_state = request.cookies.get(state_cookie(config)) or ""
status, return_to, token = "error", users.RETURN_DEFAULT, None
if state and cookie_state and hmac.compare_digest(cookie_state, state):
Expand Down Expand Up @@ -223,6 +228,115 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None:
return users.sign_in(identity, guild_member=guild_member, member=member)


# --------------------
# Branch previews sign in through dev (src/auth/preview_sign_in.py)
# --------------------

PREVIEW_START_PATH = "/api/auth/preview/start"
PREVIEW_CALLBACK_PATH = "/api/auth/preview/callback"


def _preview_start(request: Request, config: AuthConfig, return_to: str | None) -> Response:
"""On a preview: send the browser to the sign-in site, which returns it with a ticket.

Signing in again while signed in is "I've joined, check again", so dev must not hand
back the check it already has.
"""
state = secrets.token_urlsafe(32)
params = {"site": config.site_origin, "state": state, "return_to": users.clean_return_to(return_to)}
if users.session_user(request.cookies.get(session_cookie(config))) is not None:
params["recheck"] = "1"
query = urlencode(params)
response = _no_store(RedirectResponse(f"{config.sign_in_site}{PREVIEW_START_PATH}?{query}", status_code=302))
_set_cookie(response, config, state_cookie(config), state, int(users.STATE_TTL.total_seconds()))
return response


@auth_router.get("/auth/preview/start")
def preview_start(
request: Request,
site: str | None = None,
state: str | None = None,
return_to: str | None = None,
recheck: str | None = None,
):
"""On dev: hand the signed-in player to a preview, signing them in here first if needed."""
config = _config()
target = preview_sign_in.preview_site(site)
if config.via_site or target is None or not preview_sign_in.valid_token(state):
raise HTTPException(404, detail="Not Found")
back = users.clean_return_to(return_to)
# Discord's callback returns the browser here, now signed in and freshly checked.
again = PREVIEW_START_PATH + "?" + urlencode({"site": target, "state": state, "return_to": back})
if users.clean_return_to(again) != again:
again = PREVIEW_START_PATH + "?" + urlencode({"site": target, "state": state})
user = users.session_user(request.cookies.get(session_cookie(config)))
if user is None:
return discord_start(request, again)
# Linking on the preview needs a recent check, and only dev can make one: with the
# bot, or else by signing in again. `again` drops recheck, so this ends.
user, answered = guild_check.check(user)
if not answered and (recheck == "1" or not users.guild_check_recent(user)):
return discord_start(request, again)
ticket = preview_sign_in.issue(target, user)
logger.info("Preview sign-in ticket user_id=%s site=%s", user["user_id"], target)
query = urlencode({"ticket": ticket, "state": state, "return_to": back})
response = _no_store(RedirectResponse(f"{target}{PREVIEW_CALLBACK_PATH}?{query}", status_code=302))
response.headers["Referrer-Policy"] = "no-referrer"
return response


class RedeemBody(BaseModel):
ticket: str = Field(..., max_length=256)
site: str = Field(..., max_length=256)


@auth_router.post("/auth/preview/redeem")
def preview_redeem(body: RedeemBody, response: Response):
"""On dev: the preview's backend trades a ticket for the player it carries."""
config = _config()
site = preview_sign_in.preview_site(body.site)
if config.via_site or site is None:
raise HTTPException(404, detail="Not Found")
player = preview_sign_in.redeem(body.ticket, site)
if player is None:
raise HTTPException(410, detail="ticket_invalid")
_no_store(response)
return player


@auth_router.get("/auth/preview/callback")
def preview_callback(
request: Request, ticket: str | None = None, state: str | None = None, return_to: str | None = None
):
"""On a preview: open a session for the player dev's ticket carries."""
config = _config()
if not config.via_site:
raise HTTPException(404, detail="Not Found")
cookie_state = request.cookies.get(state_cookie(config)) or ""
status, token = "expired", None
if (
preview_sign_in.valid_token(state)
and preview_sign_in.valid_token(cookie_state)
and hmac.compare_digest(cookie_state, state)
and preview_sign_in.valid_token(ticket)
):
player = preview_sign_in.fetch_player(config, ticket)
token = preview_sign_in.sign_in(player) if player else None
status = "ok" if token else "error"
logger.info("Preview sign-in callback status=%s", status)
response = (
_site_redirect(config, users.clean_return_to(return_to)) if token
else _site_redirect(config, users.RETURN_DEFAULT, signin=status)
)
# The ticket is in this URL; keep it out of the next page's Referer.
response.headers["Referrer-Policy"] = "no-referrer"
_clear_cookie(response, config, state_cookie(config))
if token:
_set_cookie(response, config, session_cookie(config), token, int(users.SESSION_TTL.total_seconds()))
return response


@auth_router.post("/auth/logout")
def logout(request: Request):
config = _config()
Expand Down
18 changes: 15 additions & 3 deletions backend/src/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class AuthConfig:
guild_id: str
site_url: str
api_base: str
# A branch preview signs in through this site instead of with Discord; see preview_sign_in.py.
sign_in_site: str = ""

@classmethod
def from_env(cls) -> AuthConfig:
Expand All @@ -44,8 +46,14 @@ def from_env(cls) -> AuthConfig:
guild_id=os.getenv("DISCORD_GUILD_ID", "").strip(),
site_url=site,
api_base=(os.getenv("DISCORD_API_BASE", API_BASE_DEFAULT).strip() or API_BASE_DEFAULT).rstrip("/"),
sign_in_site=os.getenv("SIGN_IN_SITE", "").strip().rstrip("/"),
)

@property
def via_site(self) -> bool:
"""True on a branch preview, which has no Discord settings of its own."""
return bool(self.sign_in_site)

@property
def site_origin(self) -> str:
# problems() refuses an invalid site, so routes never see this fallback.
Expand All @@ -59,15 +67,19 @@ def secure_cookies(self) -> bool:
def problems(self) -> list[str]:
"""Settings that stop sign-in from working; empty when usable."""
errors = []
site = _origin(self.site_url)
if site is None or urlsplit(self.site_url).path not in {"", "/"}:
errors.append("SITE_PUBLIC_URL")
if self.via_site:
if _origin(self.sign_in_site) != self.sign_in_site or self.sign_in_site == site:
errors.append("SIGN_IN_SITE")
return errors
if not self.client_id or any(ch.isspace() for ch in self.client_id):
errors.append("DISCORD_CLIENT_ID")
if not self.client_secret:
errors.append("DISCORD_CLIENT_SECRET")
if not self.guild_id.isdigit() or len(self.guild_id) > SNOWFLAKE_MAX_LEN:
errors.append("DISCORD_GUILD_ID")
site = _origin(self.site_url)
if site is None or urlsplit(self.site_url).path not in {"", "/"}:
errors.append("SITE_PUBLIC_URL")
if not _callback_matches_site(self.redirect_uri, site) or not urlsplit(self.redirect_uri).path.endswith(
CALLBACK_ROUTE
):
Expand Down
Loading