Skip to content

Commit 92aa275

Browse files
S1ro1Copilot
andauthored
Feat: Authenticate from CLI (#230)
* Feat: start work on the auth flow * Feat: make authentication work * Fix: decode before creating in db * Fix: typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix: deduplicate state checks * Fix: correct exception types * Fix: new migration to keep linear history --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 7d7a96e commit 92aa275

6 files changed

Lines changed: 118 additions & 7 deletions

File tree

src/discord-cluster-manager/api/main.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import asyncio
2+
import base64
3+
import os
24
import time
35
from dataclasses import asdict
46

7+
import requests
58
from cogs.submit_cog import SubmitCog
69
from consts import _GPU_LOOKUP, SubmissionMode, get_gpu_by_name
710
from discord import app_commands
11+
from env import CLI_DISCORD_CLIENT_ID, CLI_DISCORD_CLIENT_SECRET, CLI_TOKEN_URL
812
from fastapi import FastAPI, HTTPException, UploadFile
913
from utils import LeaderboardItem, build_task_config
1014

@@ -53,6 +57,80 @@ async def update(self, message: str):
5357
pass
5458

5559

60+
@app.get("/auth/cli")
61+
async def cli_auth(code: str, state: str = None):
62+
"""
63+
Handle Discord OAuth redirect. This endpoint receives the authorization code
64+
and state parameter from Discord's OAuth flow.
65+
66+
Args:
67+
code (str): Authorization code from Discord OAuth
68+
state (str): Base64 encoded client ID from CLI
69+
"""
70+
71+
if not code or not state:
72+
raise HTTPException(status_code=400, detail="Missing authorization code or state")
73+
74+
client_id = CLI_DISCORD_CLIENT_ID
75+
client_secret = CLI_DISCORD_CLIENT_SECRET
76+
redirect_uri = os.environ.get("HEROKU_APP_DEFAULT_DOMAIN_NAME") or os.getenv("POPCORN_API_URL")
77+
token_url = CLI_TOKEN_URL
78+
79+
if not client_id or not client_secret:
80+
raise HTTPException(status_code=500, detail="Discord client ID or secret not configured.")
81+
82+
if not token_url:
83+
raise HTTPException(status_code=500, detail="Discord token URL not configured.")
84+
85+
if not redirect_uri:
86+
raise HTTPException(
87+
status_code=500,
88+
detail="Redirect URI not configured. "
89+
"If running locally, set env variable `POPCORN_API_URL` to your local API URL.",
90+
)
91+
92+
token_data = {
93+
"client_id": client_id,
94+
"client_secret": client_secret,
95+
"grant_type": "authorization_code",
96+
"code": code,
97+
"redirect_uri": redirect_uri + "/auth/cli",
98+
}
99+
100+
token_response = requests.post(token_url, data=token_data)
101+
if token_response.status_code != 200:
102+
raise HTTPException(
103+
status_code=401, detail=f"Failed to authenticate with Discord: {token_response.text}"
104+
)
105+
106+
token_json = token_response.json()
107+
access_token = token_json.get("access_token")
108+
109+
user_url = "https://discord.com/api/users/@me"
110+
headers = {"Authorization": f"Bearer {access_token}"}
111+
112+
user_response = requests.get(user_url, headers=headers)
113+
if user_response.status_code != 200:
114+
raise HTTPException(status_code=401, detail="Failed to retrieve user information")
115+
116+
user_json = user_response.json()
117+
user_id = user_json.get("id")
118+
user_name = user_json.get("username")
119+
120+
try:
121+
cli_id = base64.b64decode(state).decode("utf-8")
122+
except Exception:
123+
raise HTTPException(status_code=400, detail="Invalid state parameter") from None
124+
125+
with bot_instance.leaderboard_db as db:
126+
try:
127+
db.create_user_from_cli(user_id, user_name, cli_id)
128+
except Exception:
129+
raise HTTPException(status_code=400, detail="Failed to create user") from None
130+
131+
return {"status": "success", "user_id": user_id, "cli_id": cli_id, "user_name": user_name}
132+
133+
56134
@app.post("/{leaderboard_name}/{gpu_type}/{submission_mode}")
57135
async def run_submission(
58136
leaderboard_name: str, gpu_type: str, submission_mode: str, file: UploadFile

src/discord-cluster-manager/env.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def init_environment():
2121
DISCORD_CLUSTER_STAGING_ID = os.getenv("DISCORD_CLUSTER_STAGING_ID")
2222
DISCORD_DEBUG_CLUSTER_STAGING_ID = os.getenv("DISCORD_DEBUG_CLUSTER_STAGING_ID")
2323

24+
# Only required to run the CLI against this instance
25+
# setting these is required only to run the CLI against local instance
26+
CLI_DISCORD_CLIENT_ID = os.getenv("CLI_DISCORD_CLIENT_ID", "")
27+
CLI_DISCORD_CLIENT_SECRET = os.getenv("CLI_DISCORD_CLIENT_SECRET", "")
28+
CLI_TOKEN_URL = os.getenv("CLI_TOKEN_URL", "")
29+
2430
# GitHub-specific constants
2531
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
2632
GITHUB_REPO = os.getenv("GITHUB_REPO")

src/discord-cluster-manager/leaderboard_db.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,26 @@ def get_leaderboard_submission_count(
744744
self.cursor.execute(query, args)
745745
return self.cursor.fetchone()[0]
746746

747+
def create_user_from_cli(self, user_id: str, user_name: str, cli_id: str):
748+
"""
749+
Method to create a user from the CLI. Shouldn't be used for Discord.
750+
"""
751+
try:
752+
self.cursor.execute(
753+
"""
754+
INSERT INTO leaderboard.user_info (id, user_name, cli_id)
755+
VALUES (%s, %s, %s)
756+
ON CONFLICT (id) DO UPDATE
757+
SET user_name = %s, cli_id = %s
758+
""",
759+
(user_id, user_name, cli_id, user_name, cli_id),
760+
)
761+
self.connection.commit()
762+
except psycopg2.Error as e:
763+
self.connection.rollback()
764+
logger.exception("Could not create/update user %s from CLI.", user_id, exc_info=e)
765+
raise e
766+
747767

748768
if __name__ == "__main__":
749769
print(

src/discord-cluster-manager/migrations/20241226_01_ZQSOK-add_gpu_type_to_submission.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,14 @@
55

66
from yoyo import step
77

8-
__depends__ = {'20241224_01_Pg4FX-delete-cascade'}
8+
__depends__ = {"20241224_01_Pg4FX-delete-cascade"}
99

1010
steps = [
1111
step("DROP TABLE leaderboard.runinfo"),
12-
1312
step("""
1413
ALTER TABLE leaderboard.submission
1514
ADD COLUMN gpu_type TEXT NOT NULL DEFAULT 'nvidia'
1615
"""),
17-
1816
step("ALTER TABLE leaderboard.submission ADD COLUMN stdout TEXT"),
19-
2017
step("ALTER TABLE leaderboard.submission ADD COLUMN profiler_output TEXT"),
2118
]

src/discord-cluster-manager/migrations/20250221_01_GA8ro-submission-collection.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
step("DROP TABLE IF EXISTS leaderboard.submission;"),
1313
step("DROP TABLE IF EXISTS leaderboard.code_files;"),
1414
step("DROP TABLE IF EXISTS leaderboard.runs;"),
15-
1615
# create three new tables: One for deduplicating submitted code files,
1716
# one for the submission itself, and one for individual runs
1817
# The submission itself contains the code and the targeted leaderboard
@@ -25,7 +24,6 @@
2524
hash TEXT GENERATED ALWAYS AS (encode(sha256(code::bytea), 'hex')) STORED
2625
)
2726
"""),
28-
2927
step("""
3028
CREATE TABLE IF NOT EXISTS leaderboard.submission (
3129
id SERIAL PRIMARY KEY,
@@ -37,7 +35,6 @@
3735
done BOOLEAN DEFAULT FALSE
3836
)
3937
"""),
40-
4138
# the runs themselves contain information about a particular execution of that code.
4239
# This includes start and end time
4340
# Note that `score` can be NULL for non-ranked submissions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
user-info-add-cli-id
3+
"""
4+
5+
from yoyo import step
6+
7+
__depends__ = {"20250329_01_7VjJJ-add-a-secret-seed-column"}
8+
9+
steps = [
10+
step(
11+
"ALTER TABLE leaderboard.user_info ADD COLUMN IF NOT EXISTS cli_id VARCHAR(255) DEFAULT NULL;" # noqa: E501
12+
)
13+
]

0 commit comments

Comments
 (0)