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
▼apps/api/package.json
+1-0
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
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
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
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
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
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
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