feat(worker): add cloudflare worker oauth relay with turso direct write
husteno-o committed 1 months ago
f781485
1 parent 5470357
commit f781485
4 files changed
+251 -0
Showing 4 changed files+251-0
▼apps/worker/package.json
+16-0
| @@ -0,0 +1,16 @@ | ||
1 | +{ | |
2 | + "name": "@devpulse/worker", | |
3 | + "version": "1.0.0", | |
4 | + "private": true, | |
5 | + "type": "module", | |
6 | + "scripts": { | |
7 | + "build": "tsc", | |
8 | + "typecheck": "tsc --noEmit", | |
9 | + "deploy": "bun x wrangler deploy", | |
10 | + "dev": "bun x wrangler dev" | |
11 | + }, | |
12 | + "devDependencies": { | |
13 | + "@cloudflare/workers-types": "^4.20250224.0", | |
14 | + "wrangler": "^3.109.2" | |
15 | + } | |
16 | +} | |
0 | 17 | |
▼apps/worker/src/index.ts
+215-0
| @@ -0,0 +1,215 @@ | ||
1 | +// Cloudflare Worker for DevPulse GitHub OAuth Relay | |
2 | +// Relays GitHub OAuth callbacks directly to Turso DB with zero open ports on your Docker host. | |
3 | + | |
4 | +export interface Env { | |
5 | + GITHUB_CLIENT_ID: string; | |
6 | + GITHUB_CLIENT_SECRET: string; | |
7 | + TURSO_DATABASE_URL: string; | |
8 | + TURSO_AUTH_TOKEN?: string; | |
9 | + ENCRYPTION_KEY?: string; // 32-character key for AES-256-GCM | |
10 | +} | |
11 | + | |
12 | +// Convert string/passphrase into AES-256-GCM CryptoKey | |
13 | +async function getCryptoKey(secretKey: string): Promise<CryptoKey> { | |
14 | + const enc = new TextEncoder(); | |
15 | + const keyMaterial = await crypto.subtle.importKey( | |
16 | + "raw", | |
17 | + enc.encode(secretKey.padEnd(32, "0").slice(0, 32)), | |
18 | + { name: "AES-GCM" }, | |
19 | + false, | |
20 | + ["encrypt"], | |
21 | + ); | |
22 | + return keyMaterial; | |
23 | +} | |
24 | + | |
25 | +// Encrypt token using Web Crypto AES-256-GCM | |
26 | +async function encryptToken(token: string, secretKey: string): Promise<string> { | |
27 | + const key = await getCryptoKey(secretKey); | |
28 | + const iv = crypto.getRandomValues(new Uint8Array(12)); | |
29 | + const enc = new TextEncoder(); | |
30 | + | |
31 | + const ciphertextBuffer = await crypto.subtle.encrypt( | |
32 | + { name: "AES-GCM", iv, tagLength: 128 }, | |
33 | + key, | |
34 | + enc.encode(token), | |
35 | + ); | |
36 | + | |
37 | + const ivHex = Array.from(iv) | |
38 | + .map((b) => b.toString(16).padStart(2, "0")) | |
39 | + .join(""); | |
40 | + const encryptedHex = Array.from(new Uint8Array(ciphertextBuffer)) | |
41 | + .map((b) => b.toString(16).padStart(2, "0")) | |
42 | + .join(""); | |
43 | + return `${ivHex}:${encryptedHex}`; | |
44 | +} | |
45 | + | |
46 | +// Execute query against Turso via its HTTP pipeline API | |
47 | +async function executeTurso( | |
48 | + databaseUrl: string, | |
49 | + authToken: string | undefined, | |
50 | + sql: string, | |
51 | + args: any[], | |
52 | +) { | |
53 | + const httpUrl = databaseUrl.replace(/^libsql:\/\//, "https://"); | |
54 | + const endpoint = `${httpUrl}/v2/pipeline`; | |
55 | + | |
56 | + const body = { | |
57 | + requests: [ | |
58 | + { | |
59 | + type: "execute", | |
60 | + stmt: { | |
61 | + sql, | |
62 | + args: args.map((arg) => { | |
63 | + if (arg === null || arg === undefined) return { type: "null" }; | |
64 | + if (typeof arg === "number") return { type: "integer", value: arg.toString() }; | |
65 | + return { type: "text", value: String(arg) }; | |
66 | + }), | |
67 | + }, | |
68 | + }, | |
69 | + { type: "close" }, | |
70 | + ], | |
71 | + }; | |
72 | + | |
73 | + const response = await fetch(endpoint, { | |
74 | + method: "POST", | |
75 | + headers: { | |
76 | + "Content-Type": "application/json", | |
77 | + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), | |
78 | + }, | |
79 | + body: JSON.stringify(body), | |
80 | + }); | |
81 | + | |
82 | + return response.json(); | |
83 | +} | |
84 | + | |
85 | +export default { | |
86 | + async fetch(request: Request, env: Env): Promise<Response> { | |
87 | + const url = new URL(request.url); | |
88 | + | |
89 | + // 1. Health check | |
90 | + if (url.pathname === "/" || url.pathname === "/health") { | |
91 | + return new Response( | |
92 | + JSON.stringify({ status: "healthy", service: "DevPulse Cloudflare Auth Relay" }), | |
93 | + { | |
94 | + headers: { "Content-Type": "application/json" }, | |
95 | + }, | |
96 | + ); | |
97 | + } | |
98 | + | |
99 | + // 2. Login Redirect | |
100 | + if (url.pathname === "/auth/github/login") { | |
101 | + const userId = url.searchParams.get("userId") || "anonymous"; | |
102 | + const redirectUri = `${url.origin}/auth/github/callback`; | |
103 | + const githubUrl = `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent( | |
104 | + env.GITHUB_CLIENT_ID, | |
105 | + )}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,read:user&state=${encodeURIComponent(userId)}`; | |
106 | + | |
107 | + return Response.redirect(githubUrl, 302); | |
108 | + } | |
109 | + | |
110 | + // 3. OAuth Callback | |
111 | + if (url.pathname === "/auth/github/callback") { | |
112 | + const code = url.searchParams.get("code"); | |
113 | + const state = url.searchParams.get("state"); | |
114 | + | |
115 | + if (!code) { | |
116 | + return new Response("Missing code parameter", { status: 400 }); | |
117 | + } | |
118 | + | |
119 | + // Exchange code for access token | |
120 | + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { | |
121 | + method: "POST", | |
122 | + headers: { | |
123 | + Accept: "application/json", | |
124 | + "Content-Type": "application/json", | |
125 | + }, | |
126 | + body: JSON.stringify({ | |
127 | + client_id: env.GITHUB_CLIENT_ID, | |
128 | + client_secret: env.GITHUB_CLIENT_SECRET, | |
129 | + code, | |
130 | + }), | |
131 | + }); | |
132 | + | |
133 | + const tokenData = (await tokenRes.json()) as { | |
134 | + access_token?: string; | |
135 | + error?: string; | |
136 | + error_description?: string; | |
137 | + }; | |
138 | + if (!tokenData.access_token) { | |
139 | + return new Response( | |
140 | + `Authentication error: ${tokenData.error_description || "Failed to acquire token"}`, | |
141 | + { status: 400 }, | |
142 | + ); | |
143 | + } | |
144 | + | |
145 | + // Fetch GitHub profile | |
146 | + const userRes = await fetch("https://api.github.com/user", { | |
147 | + headers: { | |
148 | + Authorization: `Bearer ${tokenData.access_token}`, | |
149 | + "User-Agent": "DevPulse-Cloudflare-Relay", | |
150 | + }, | |
151 | + }); | |
152 | + const ghUser = (await userRes.json()) as { login: string }; | |
153 | + | |
154 | + // Encrypt token | |
155 | + const encKey = | |
156 | + env.ENCRYPTION_KEY || env.GITHUB_CLIENT_SECRET || "devpulse-default-secure-key-32"; | |
157 | + const encrypted = await encryptToken(tokenData.access_token, encKey); | |
158 | + const discordUserId = state && state !== "anonymous" ? state : null; | |
159 | + | |
160 | + // Persist directly to shared Turso Database | |
161 | + if (discordUserId && env.TURSO_DATABASE_URL) { | |
162 | + const now = Date.now(); | |
163 | + const upsertSql = ` | |
164 | + INSERT INTO users (id, username, github_username, github_token_encrypted, updated_at) | |
165 | + VALUES (?, ?, ?, ?, ?) | |
166 | + ON CONFLICT(id) DO UPDATE SET | |
167 | + github_username = excluded.github_username, | |
168 | + github_token_encrypted = excluded.github_token_encrypted, | |
169 | + updated_at = excluded.updated_at | |
170 | + `; | |
171 | + await executeTurso(env.TURSO_DATABASE_URL, env.TURSO_AUTH_TOKEN, upsertSql, [ | |
172 | + discordUserId, | |
173 | + ghUser.login, | |
174 | + ghUser.login, | |
175 | + encrypted, | |
176 | + now, | |
177 | + ]); | |
178 | + } | |
179 | + | |
180 | + // Return success page | |
181 | + const html = ` | |
182 | + <!DOCTYPE html> | |
183 | + <html> | |
184 | + <head> | |
185 | + <meta charset="UTF-8" /> | |
186 | + <title>DevPulse - Connected</title> | |
187 | + <style> | |
188 | + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #f0f6fc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; } | |
189 | + .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px; text-align: center; max-width: 440px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); } | |
190 | + h1 { color: #3fb950; font-size: 1.5rem; margin-bottom: 12px; } | |
191 | + p { color: #8b949e; line-height: 1.6; margin-bottom: 24px; font-size: 0.95rem; } | |
192 | + .badge { display: inline-block; background: rgba(56,139,253,0.15); color: #58a6ff; border: 1px solid rgba(56,139,253,0.4); padding: 4px 12px; border-radius: 6px; font-family: monospace; font-size: 0.9rem; margin-bottom: 20px; } | |
193 | + .footer { font-size: 0.8rem; color: #6e7681; } | |
194 | + </style> | |
195 | + </head> | |
196 | + <body> | |
197 | + <div class="card"> | |
198 | + <h1>GitHub Connected</h1> | |
199 | + <div class="badge">@${ghUser.login}</div> | |
200 | + <p>Your GitHub account has been securely linked and saved to your database. Your Docker bot is now connected without exposing any ports to the internet.</p> | |
201 | + <p>You can close this tab and return to Discord!</p> | |
202 | + <div class="footer">DevPulse via Cloudflare Edge Relay</div> | |
203 | + </div> | |
204 | + </body> | |
205 | + </html> | |
206 | + `; | |
207 | + | |
208 | + return new Response(html, { | |
209 | + headers: { "Content-Type": "text/html; charset=utf-8" }, | |
210 | + }); | |
211 | + } | |
212 | + | |
213 | + return new Response("Not Found", { status: 404 }); | |
214 | + }, | |
215 | +}; | |
0 | 216 | |
▼apps/worker/tsconfig.json
+10-0
| @@ -0,0 +1,10 @@ | ||
1 | +{ | |
2 | + "extends": "../../tsconfig.json", | |
3 | + "compilerOptions": { | |
4 | + "rootDir": "./src", | |
5 | + "outDir": "./dist", | |
6 | + "types": ["bun-types"], | |
7 | + "lib": ["ESNext", "DOM"] | |
8 | + }, | |
9 | + "include": ["src/**/*"] | |
10 | +} | |
0 | 11 | |
▼apps/worker/wrangler.toml
+10-0
| @@ -0,0 +1,10 @@ | ||
1 | +name = "devpulse-auth" | |
2 | +main = "src/index.ts" | |
3 | +compatibility_date = "2024-09-01" | |
4 | + | |
5 | +# Environment variables configured via dashboard or `wrangler secret put` | |
6 | +# Required Secrets: | |
7 | +# - GITHUB_CLIENT_ID | |
8 | +# - GITHUB_CLIENT_SECRET | |
9 | +# - TURSO_DATABASE_URL | |
10 | +# - TURSO_AUTH_TOKEN | |
0 | 11 | |