feat: implement GitHub OAuth login and callback endpoints with AES token storage
husteno-o committed 1 months ago
5470357
1 parent a236240
commit 5470357
4 files changed
+155 -2
Showing 4 changed files+155-2
▼apps/api/package.json
+1-0
| @@ -17,6 +17,7 @@ | ||
17 | 17 | "@devpulse/database": "workspace:*", |
18 | 18 | "@devpulse/logger": "workspace:*", |
19 | 19 | "@devpulse/monitoring": "workspace:*", |
20 | + "@devpulse/security": "workspace:*", | |
20 | 21 | "drizzle-orm": "^0.38.4", |
21 | 22 | "fastify": "^5.2.1" |
22 | 23 | }, |
23 | 24 | |
▼apps/api/src/server.ts
+141-2
| @@ -1,8 +1,10 @@ | ||
1 | 1 | import { cache } from "@devpulse/cache"; |
2 | +import { config } from "@devpulse/config"; | |
2 | 3 | import { formatBytes } from "@devpulse/core"; |
3 | -import { db } from "@devpulse/database"; | |
4 | +import { db, users } from "@devpulse/database"; | |
4 | 5 | import { logger } from "@devpulse/logger"; |
5 | -import { sql } from "drizzle-orm"; | |
6 | +import { encryptSecret } from "@devpulse/security"; | |
7 | +import { eq, sql } from "drizzle-orm"; | |
6 | 8 | import Fastify, { type FastifyInstance } from "fastify"; |
7 | 9 | |
8 | 10 | export interface ServerOptions { |
| @@ -92,5 +94,142 @@ export function buildServer(options?: ServerOptions): FastifyInstance { | ||
92 | 94 | }; |
93 | 95 | }); |
94 | 96 | |
97 | + // -------------------------------------------------------------------------- | |
98 | + // GitHub OAuth Endpoints | |
99 | + // -------------------------------------------------------------------------- | |
100 | + server.get("/auth/github/login", async (req, reply) => { | |
101 | + const { userId } = req.query as { userId?: string }; | |
102 | + if (!config.GITHUB_CLIENT_ID) { | |
103 | + return reply | |
104 | + .status(500) | |
105 | + .type("text/html") | |
106 | + .send("<h1>Error: GITHUB_CLIENT_ID is not configured in .env</h1>"); | |
107 | + } | |
108 | + | |
109 | + const redirectUri = | |
110 | + config.GITHUB_REDIRECT_URI || | |
111 | + `${req.protocol}://${req.headers.host || req.hostname}/auth/github/callback`; | |
112 | + const state = userId || "anonymous"; | |
113 | + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent( | |
114 | + config.GITHUB_CLIENT_ID, | |
115 | + )}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,read:user&state=${encodeURIComponent(state)}`; | |
116 | + | |
117 | + return reply.redirect(githubAuthUrl); | |
118 | + }); | |
119 | + | |
120 | + server.get("/auth/github/callback", async (req, reply) => { | |
121 | + const { code, state } = req.query as { code?: string; state?: string }; | |
122 | + | |
123 | + if (!code) { | |
124 | + return reply | |
125 | + .status(400) | |
126 | + .type("text/html") | |
127 | + .send("<h1>Error: Missing authorization code from GitHub</h1>"); | |
128 | + } | |
129 | + | |
130 | + if (!config.GITHUB_CLIENT_ID || !config.GITHUB_CLIENT_SECRET) { | |
131 | + return reply | |
132 | + .status(500) | |
133 | + .type("text/html") | |
134 | + .send("<h1>Error: GitHub OAuth credentials not configured on server</h1>"); | |
135 | + } | |
136 | + | |
137 | + try { | |
138 | + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { | |
139 | + method: "POST", | |
140 | + headers: { | |
141 | + Accept: "application/json", | |
142 | + "Content-Type": "application/json", | |
143 | + }, | |
144 | + body: JSON.stringify({ | |
145 | + client_id: config.GITHUB_CLIENT_ID, | |
146 | + client_secret: config.GITHUB_CLIENT_SECRET, | |
147 | + code, | |
148 | + }), | |
149 | + }); | |
150 | + | |
151 | + const tokenData = (await tokenRes.json()) as { | |
152 | + access_token?: string; | |
153 | + error?: string; | |
154 | + error_description?: string; | |
155 | + }; | |
156 | + | |
157 | + if (!tokenData.access_token) { | |
158 | + return reply | |
159 | + .status(400) | |
160 | + .type("text/html") | |
161 | + .send( | |
162 | + `<h1>Authentication Failed</h1><p>${tokenData.error_description || "Could not retrieve access token."}</p>`, | |
163 | + ); | |
164 | + } | |
165 | + | |
166 | + const userRes = await fetch("https://api.github.com/user", { | |
167 | + headers: { | |
168 | + Authorization: `Bearer ${tokenData.access_token}`, | |
169 | + "User-Agent": "DevPulse-Discord-Bot", | |
170 | + }, | |
171 | + }); | |
172 | + | |
173 | + const ghUser = (await userRes.json()) as { login: string; id: number }; | |
174 | + const encrypted = encryptSecret(tokenData.access_token); | |
175 | + const discordUserId = state && state !== "anonymous" ? state : null; | |
176 | + | |
177 | + if (discordUserId) { | |
178 | + const existing = await db.query.users.findFirst({ | |
179 | + where: eq(users.id, discordUserId), | |
180 | + }); | |
181 | + | |
182 | + if (existing) { | |
183 | + await db | |
184 | + .update(users) | |
185 | + .set({ | |
186 | + githubUsername: ghUser.login, | |
187 | + githubTokenEncrypted: encrypted, | |
188 | + updatedAt: new Date(), | |
189 | + }) | |
190 | + .where(eq(users.id, discordUserId)); | |
191 | + } else { | |
192 | + await db.insert(users).values({ | |
193 | + id: discordUserId, | |
194 | + username: ghUser.login, | |
195 | + githubUsername: ghUser.login, | |
196 | + githubTokenEncrypted: encrypted, | |
197 | + }); | |
198 | + } | |
199 | + } | |
200 | + | |
201 | + return reply.type("text/html").send(` | |
202 | + <!DOCTYPE html> | |
203 | + <html> | |
204 | + <head> | |
205 | + <title>DevPulse - Connected</title> | |
206 | + <style> | |
207 | + 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; } | |
208 | + .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); } | |
209 | + h1 { color: #3fb950; font-size: 1.5rem; margin-bottom: 12px; } | |
210 | + p { color: #8b949e; line-height: 1.6; margin-bottom: 24px; font-size: 0.95rem; } | |
211 | + .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; } | |
212 | + .footer { font-size: 0.8rem; color: #6e7681; } | |
213 | + </style> | |
214 | + </head> | |
215 | + <body> | |
216 | + <div class="card"> | |
217 | + <h1>GitHub Connected</h1> | |
218 | + <div class="badge">@${ghUser.login}</div> | |
219 | + <p>Your GitHub account has been securely linked and encrypted with AES-256-GCM. You can now close this tab and return to Discord!</p> | |
220 | + <div class="footer">DevPulse Developer Operating System</div> | |
221 | + </div> | |
222 | + </body> | |
223 | + </html> | |
224 | + `); | |
225 | + } catch (err: any) { | |
226 | + logger.error({ err }, "GitHub OAuth exchange failed"); | |
227 | + return reply | |
228 | + .status(500) | |
229 | + .type("text/html") | |
230 | + .send(`<h1>Internal Server Error</h1><p>${err.message}</p>`); | |
231 | + } | |
232 | + }); | |
233 | + | |
95 | 234 | return server; |
96 | 235 | } |
97 | 236 | |
▼bun.lock
+12-0
| @@ -22,6 +22,7 @@ | ||
22 | 22 | "@devpulse/database": "workspace:*", |
23 | 23 | "@devpulse/logger": "workspace:*", |
24 | 24 | "@devpulse/monitoring": "workspace:*", |
25 | + "@devpulse/security": "workspace:*", | |
25 | 26 | "drizzle-orm": "^0.38.4", |
26 | 27 | "fastify": "^5.2.1", |
27 | 28 | }, |
| @@ -56,6 +57,15 @@ | ||
56 | 57 | "typescript": "^5.7.3", |
57 | 58 | }, |
58 | 59 | }, |
60 | + "apps/web": { | |
61 | + "name": "@devpulse/web", | |
62 | + "version": "1.0.0", | |
63 | + "devDependencies": { | |
64 | + "@types/node": "^22.13.0", | |
65 | + "bun-types": "^1.4.2", | |
66 | + "typescript": "^5.7.3", | |
67 | + }, | |
68 | + }, | |
59 | 69 | "packages/ai": { |
60 | 70 | "name": "@devpulse/ai", |
61 | 71 | "version": "1.0.0", |
| @@ -314,6 +324,8 @@ | ||
314 | 324 | |
315 | 325 | "@devpulse/trending": ["@devpulse/trending@workspace:packages/trending"], |
316 | 326 | |
327 | + "@devpulse/web": ["@devpulse/web@workspace:apps/web"], | |
328 | + | |
317 | 329 | "@discordjs/builders": ["@discordjs/[email protected]", "https://registry.npmmirror.com/@discordjs/builders/-/builders-1.14.1.tgz", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], |
318 | 330 | |
319 | 331 | "@discordjs/collection": ["@discordjs/[email protected]", "https://registry.npmmirror.com/@discordjs/collection/-/collection-1.5.3.tgz", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], |
320 | 332 | |
▼packages/config/src/index.ts
+1-0
| @@ -24,6 +24,7 @@ export const ConfigSchema = z.object({ | ||
24 | 24 | GITHUB_TOKEN: z.string().optional(), |
25 | 25 | GITHUB_CLIENT_ID: z.string().optional(), |
26 | 26 | GITHUB_CLIENT_SECRET: z.string().optional(), |
27 | + GITHUB_REDIRECT_URI: z.string().optional(), | |
27 | 28 | |
28 | 29 | // Optional AI integration |
29 | 30 | AI_PROVIDER: z.enum(["openai", "anthropic", "gemini", "none"]).default("none"), |
30 | 31 | |