@terreno/api 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.windsurfrules +107 -0
- package/AGENTS.md +313 -0
- package/README.md +3 -4
- package/biome.jsonc +1 -1
- package/dist/api.js +1 -1
- package/dist/api.query.test.js +1 -1
- package/dist/api.test.js +36 -1202
- package/dist/errors.js +1 -1
- package/dist/expressServer.d.ts +8 -2
- package/dist/expressServer.js +8 -1
- package/dist/githubAuth.d.ts +64 -0
- package/dist/githubAuth.js +293 -0
- package/dist/githubAuth.test.d.ts +1 -0
- package/dist/githubAuth.test.js +351 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/logger.js +1 -1
- package/dist/middleware.js +1 -1
- package/dist/notifiers/googleChatNotifier.js +1 -1
- package/dist/notifiers/googleChatNotifier.test.js +1 -1
- package/dist/notifiers/slackNotifier.js +1 -1
- package/dist/notifiers/slackNotifier.test.js +1 -1
- package/dist/notifiers/zoomNotifier.js +1 -1
- package/dist/notifiers/zoomNotifier.test.js +1 -1
- package/dist/permissions.js +1 -1
- package/dist/tests/bunSetup.js +2 -2
- package/package.json +4 -2
- package/src/api.query.test.ts +1 -1
- package/src/api.test.ts +30 -984
- package/src/api.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/expressServer.ts +18 -2
- package/src/githubAuth.test.ts +223 -0
- package/src/githubAuth.ts +335 -0
- package/src/index.ts +1 -0
- package/src/logger.ts +1 -1
- package/src/middleware.ts +1 -1
- package/src/notifiers/googleChatNotifier.test.ts +1 -1
- package/src/notifiers/googleChatNotifier.ts +1 -1
- package/src/notifiers/slackNotifier.test.ts +1 -1
- package/src/notifiers/slackNotifier.ts +1 -1
- package/src/notifiers/zoomNotifier.test.ts +1 -1
- package/src/notifiers/zoomNotifier.ts +1 -1
- package/src/permissions.ts +1 -1
- package/src/tests/bunSetup.ts +2 -2
- /package/{CLAUDE.md → .cursorrules} +0 -0
package/src/api.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import * as Sentry from "@sentry/
|
|
6
|
+
import * as Sentry from "@sentry/bun";
|
|
7
7
|
import express, {type NextFunction, type Request, type Response} from "express";
|
|
8
8
|
import cloneDeep from "lodash/cloneDeep";
|
|
9
9
|
import mongoose, {type Document, type Model} from "mongoose";
|
package/src/errors.ts
CHANGED
package/src/expressServer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as Sentry from "@sentry/
|
|
1
|
+
import * as Sentry from "@sentry/bun";
|
|
2
2
|
import openapi from "@wesleytodd/openapi";
|
|
3
3
|
import cors from "cors";
|
|
4
4
|
import cron from "cron";
|
|
@@ -12,6 +12,7 @@ import qs from "qs";
|
|
|
12
12
|
import type {ModelRouterOptions} from "./api";
|
|
13
13
|
import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
|
|
14
14
|
import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
|
|
15
|
+
import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
|
|
15
16
|
import {type LoggingOptions, logger, setupLogging} from "./logger";
|
|
16
17
|
import {sendToSlack} from "./notifiers";
|
|
17
18
|
import {openApiEtagMiddleware} from "./openApiEtag";
|
|
@@ -168,6 +169,8 @@ interface InitializeRoutesOptions {
|
|
|
168
169
|
logRequests?: boolean;
|
|
169
170
|
loggingOptions?: LoggingOptions;
|
|
170
171
|
authOptions?: AuthOptions;
|
|
172
|
+
/** GitHub OAuth configuration. When provided, enables GitHub authentication. */
|
|
173
|
+
githubAuth?: GitHubAuthOptions;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
function initializeRoutes(
|
|
@@ -241,6 +244,13 @@ function initializeRoutes(
|
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
addMeRoutes(app, UserModel as any, options?.authOptions);
|
|
247
|
+
|
|
248
|
+
// Set up GitHub OAuth if configured
|
|
249
|
+
if (options.githubAuth) {
|
|
250
|
+
setupGitHubAuth(app, UserModel as any, options.githubAuth);
|
|
251
|
+
addGitHubAuthRoutes(app, UserModel as any, options.githubAuth, options.authOptions);
|
|
252
|
+
}
|
|
253
|
+
|
|
244
254
|
addRoutes(app, {openApi: oapi});
|
|
245
255
|
|
|
246
256
|
Sentry.setupExpressErrorHandler(app);
|
|
@@ -264,6 +274,11 @@ export interface SetupServerOptions {
|
|
|
264
274
|
addRoutes: AddRoutes;
|
|
265
275
|
loggingOptions?: LoggingOptions;
|
|
266
276
|
authOptions?: AuthOptions;
|
|
277
|
+
/**
|
|
278
|
+
* GitHub OAuth configuration. When provided, enables GitHub authentication.
|
|
279
|
+
* Requires the user schema to have GitHub fields (use githubUserPlugin).
|
|
280
|
+
*/
|
|
281
|
+
githubAuth?: GitHubAuthOptions;
|
|
267
282
|
skipListen?: boolean;
|
|
268
283
|
corsOrigin?:
|
|
269
284
|
| string
|
|
@@ -279,7 +294,7 @@ export interface SetupServerOptions {
|
|
|
279
294
|
) => void);
|
|
280
295
|
addMiddleware?: AddRoutes;
|
|
281
296
|
ignoreTraces?: string[];
|
|
282
|
-
sentryOptions?: Sentry.
|
|
297
|
+
sentryOptions?: Sentry.BunOptions;
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
// Sets up the routes and returns a function to launch the API.
|
|
@@ -295,6 +310,7 @@ export function setupServer(options: SetupServerOptions) {
|
|
|
295
310
|
addMiddleware: options.addMiddleware,
|
|
296
311
|
authOptions: options.authOptions,
|
|
297
312
|
corsOrigin: options.corsOrigin,
|
|
313
|
+
githubAuth: options.githubAuth,
|
|
298
314
|
});
|
|
299
315
|
} catch (error: any) {
|
|
300
316
|
logger.error(`Error initializing routes: ${error.stack}`);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, setSystemTime} from "bun:test";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import mongoose, {model, Schema} from "mongoose";
|
|
4
|
+
import passportLocalMongoose from "passport-local-mongoose";
|
|
5
|
+
import supertest from "supertest";
|
|
6
|
+
import type TestAgent from "supertest/lib/agent";
|
|
7
|
+
|
|
8
|
+
import {setupServer} from "./expressServer";
|
|
9
|
+
import {type GitHubUserFields, githubUserPlugin} from "./githubAuth";
|
|
10
|
+
import {logger} from "./logger";
|
|
11
|
+
import {createdUpdatedPlugin, isDisabledPlugin} from "./plugins";
|
|
12
|
+
|
|
13
|
+
interface TestUser extends GitHubUserFields {
|
|
14
|
+
admin: boolean;
|
|
15
|
+
name?: string;
|
|
16
|
+
username: string;
|
|
17
|
+
email: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Create schema for GitHub-enabled user
|
|
22
|
+
const testUserSchema = new Schema<TestUser>({
|
|
23
|
+
admin: {default: false, type: Boolean},
|
|
24
|
+
name: String,
|
|
25
|
+
username: String,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
testUserSchema.plugin(passportLocalMongoose as any, {
|
|
29
|
+
attemptsField: "attempts",
|
|
30
|
+
interval: 1,
|
|
31
|
+
limitAttempts: true,
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
maxInterval: 1,
|
|
34
|
+
usernameCaseInsensitive: true,
|
|
35
|
+
usernameField: "email",
|
|
36
|
+
});
|
|
37
|
+
testUserSchema.plugin(createdUpdatedPlugin);
|
|
38
|
+
testUserSchema.plugin(isDisabledPlugin);
|
|
39
|
+
testUserSchema.plugin(githubUserPlugin);
|
|
40
|
+
|
|
41
|
+
// Get or create model to avoid model redefinition errors
|
|
42
|
+
const GitHubTestUserModel =
|
|
43
|
+
mongoose.models.GitHubTestUser || model<TestUser>("GitHubTestUser", testUserSchema);
|
|
44
|
+
|
|
45
|
+
// Connect to database before tests
|
|
46
|
+
const connectDb = async () => {
|
|
47
|
+
if (mongoose.connection.readyState === 0) {
|
|
48
|
+
await mongoose
|
|
49
|
+
.connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
|
|
50
|
+
.catch(logger.catch);
|
|
51
|
+
}
|
|
52
|
+
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
53
|
+
process.env.TOKEN_SECRET = "secret";
|
|
54
|
+
process.env.TOKEN_EXPIRES_IN = "30m";
|
|
55
|
+
process.env.TOKEN_ISSUER = "example.com";
|
|
56
|
+
process.env.SESSION_SECRET = "session";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("githubUserPlugin", () => {
|
|
60
|
+
it("adds GitHub fields to schema", () => {
|
|
61
|
+
const paths = testUserSchema.paths;
|
|
62
|
+
expect(paths.githubId).toBeDefined();
|
|
63
|
+
expect(paths.githubUsername).toBeDefined();
|
|
64
|
+
expect(paths.githubProfileUrl).toBeDefined();
|
|
65
|
+
expect(paths.githubAvatarUrl).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("githubId is indexed and sparse", () => {
|
|
69
|
+
const githubIdPath = testUserSchema.path("githubId");
|
|
70
|
+
expect((githubIdPath as any).options.index).toBe(true);
|
|
71
|
+
expect((githubIdPath as any).options.sparse).toBe(true);
|
|
72
|
+
expect((githubIdPath as any).options.unique).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("GitHub auth routes", () => {
|
|
77
|
+
let app: express.Application;
|
|
78
|
+
let agent: TestAgent;
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
setSystemTime();
|
|
82
|
+
await connectDb();
|
|
83
|
+
|
|
84
|
+
await GitHubTestUserModel.deleteMany({});
|
|
85
|
+
|
|
86
|
+
// Create test user with password
|
|
87
|
+
const testUser = await GitHubTestUserModel.create({
|
|
88
|
+
admin: false,
|
|
89
|
+
email: "test@example.com",
|
|
90
|
+
name: "Test User",
|
|
91
|
+
});
|
|
92
|
+
await (testUser as any).setPassword("password123");
|
|
93
|
+
await testUser.save();
|
|
94
|
+
|
|
95
|
+
function addRoutes(router: express.Router): void {
|
|
96
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
app = setupServer({
|
|
100
|
+
addRoutes,
|
|
101
|
+
githubAuth: {
|
|
102
|
+
allowAccountLinking: true,
|
|
103
|
+
callbackURL: "http://localhost:9000/auth/github/callback",
|
|
104
|
+
clientId: "test-client-id",
|
|
105
|
+
clientSecret: "test-client-secret",
|
|
106
|
+
},
|
|
107
|
+
skipListen: true,
|
|
108
|
+
userModel: GitHubTestUserModel as any,
|
|
109
|
+
});
|
|
110
|
+
agent = supertest.agent(app);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
setSystemTime();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("GET /auth/github redirects to GitHub OAuth", async () => {
|
|
118
|
+
const res = await agent.get("/auth/github").expect(302);
|
|
119
|
+
expect(res.headers.location).toContain("github.com");
|
|
120
|
+
expect(res.headers.location).toContain("client_id=test-client-id");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("GET /auth/github/failure returns 401", async () => {
|
|
124
|
+
const res = await agent.get("/auth/github/failure").expect(401);
|
|
125
|
+
expect(res.body.message).toBe("GitHub authentication failed");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("DELETE /auth/github/unlink requires authentication", async () => {
|
|
129
|
+
const res = await agent.delete("/auth/github/unlink").expect(401);
|
|
130
|
+
expect(res.body).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("DELETE /auth/github/unlink works when authenticated with password", async () => {
|
|
134
|
+
// Login as test user
|
|
135
|
+
const loginRes = await agent
|
|
136
|
+
.post("/auth/login")
|
|
137
|
+
.send({email: "test@example.com", password: "password123"})
|
|
138
|
+
.expect(200);
|
|
139
|
+
|
|
140
|
+
// Link github to this user
|
|
141
|
+
const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
142
|
+
if (user) {
|
|
143
|
+
(user as any).githubId = "99999";
|
|
144
|
+
(user as any).githubUsername = "testghuser";
|
|
145
|
+
await user.save();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Unlink
|
|
149
|
+
const res = await agent
|
|
150
|
+
.delete("/auth/github/unlink")
|
|
151
|
+
.set("authorization", `Bearer ${loginRes.body.data.token}`)
|
|
152
|
+
.expect(200);
|
|
153
|
+
|
|
154
|
+
expect(res.body.data.message).toBe("GitHub account unlinked successfully");
|
|
155
|
+
|
|
156
|
+
// Verify github fields are cleared
|
|
157
|
+
const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
158
|
+
expect((updatedUser as any).githubId).toBeUndefined();
|
|
159
|
+
expect((updatedUser as any).githubUsername).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("user can have both password and GitHub auth", async () => {
|
|
163
|
+
const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
164
|
+
expect(user).toBeDefined();
|
|
165
|
+
if (!user) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Link GitHub
|
|
170
|
+
(user as any).githubId = "88888";
|
|
171
|
+
(user as any).githubUsername = "linkeduser";
|
|
172
|
+
await user.save();
|
|
173
|
+
|
|
174
|
+
// Can still login with password
|
|
175
|
+
const res = await agent
|
|
176
|
+
.post("/auth/login")
|
|
177
|
+
.send({email: "test@example.com", password: "password123"})
|
|
178
|
+
.expect(200);
|
|
179
|
+
|
|
180
|
+
expect(res.body.data.token).toBeDefined();
|
|
181
|
+
|
|
182
|
+
// User has both auth methods - successful login proves password works
|
|
183
|
+
// and we verify GitHub fields are set
|
|
184
|
+
const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
|
|
185
|
+
expect(updatedUser).toBeDefined();
|
|
186
|
+
expect((updatedUser as any).githubId).toBe("88888");
|
|
187
|
+
expect((updatedUser as any).githubUsername).toBe("linkeduser");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("GitHub auth disabled", () => {
|
|
192
|
+
let app: express.Application;
|
|
193
|
+
let agent: TestAgent;
|
|
194
|
+
|
|
195
|
+
beforeEach(async () => {
|
|
196
|
+
setSystemTime();
|
|
197
|
+
await connectDb();
|
|
198
|
+
|
|
199
|
+
await GitHubTestUserModel.deleteMany({});
|
|
200
|
+
|
|
201
|
+
function addRoutes(router: express.Router): void {
|
|
202
|
+
router.get("/test", (_req, res) => res.json({ok: true}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Setup server WITHOUT GitHub auth
|
|
206
|
+
app = setupServer({
|
|
207
|
+
addRoutes,
|
|
208
|
+
skipListen: true,
|
|
209
|
+
userModel: GitHubTestUserModel as any,
|
|
210
|
+
});
|
|
211
|
+
agent = supertest.agent(app);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
afterEach(async () => {
|
|
215
|
+
setSystemTime();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("GitHub routes are not available when githubAuth is not configured", async () => {
|
|
219
|
+
await agent.get("/auth/github").expect(404);
|
|
220
|
+
await agent.get("/auth/github/callback").expect(404);
|
|
221
|
+
await agent.delete("/auth/github/unlink").expect(404);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type express from "express";
|
|
2
|
+
import passport from "passport";
|
|
3
|
+
import {Strategy as GitHubStrategy, type Profile} from "passport-github2";
|
|
4
|
+
import {generateTokens, type UserModel} from "./auth";
|
|
5
|
+
import {APIError} from "./errors";
|
|
6
|
+
import type {AuthOptions} from "./expressServer";
|
|
7
|
+
import {logger} from "./logger";
|
|
8
|
+
|
|
9
|
+
/** Options for configuring GitHub OAuth authentication */
|
|
10
|
+
export interface GitHubAuthOptions {
|
|
11
|
+
/** GitHub OAuth Client ID */
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** GitHub OAuth Client Secret */
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
/** Callback URL for GitHub OAuth (e.g., https://yourapp.com/auth/github/callback) */
|
|
16
|
+
callbackURL: string;
|
|
17
|
+
/** OAuth scopes to request from GitHub. Defaults to ["user:email"] */
|
|
18
|
+
scope?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Whether to allow linking GitHub to existing accounts.
|
|
21
|
+
* If true, authenticated users can link their GitHub account.
|
|
22
|
+
* Defaults to true.
|
|
23
|
+
*/
|
|
24
|
+
allowAccountLinking?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Custom function to handle user creation or lookup from GitHub profile.
|
|
27
|
+
* If not provided, a default implementation will be used.
|
|
28
|
+
*/
|
|
29
|
+
findOrCreateUser?: (
|
|
30
|
+
profile: Profile,
|
|
31
|
+
accessToken: string,
|
|
32
|
+
refreshToken: string,
|
|
33
|
+
existingUser?: any
|
|
34
|
+
) => Promise<any>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fields added to user documents for GitHub authentication */
|
|
38
|
+
export interface GitHubUserFields {
|
|
39
|
+
/** GitHub user ID */
|
|
40
|
+
githubId?: string;
|
|
41
|
+
/** GitHub username */
|
|
42
|
+
githubUsername?: string;
|
|
43
|
+
/** GitHub profile URL */
|
|
44
|
+
githubProfileUrl?: string;
|
|
45
|
+
/** GitHub avatar URL */
|
|
46
|
+
githubAvatarUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Plugin to add GitHub authentication fields to a user schema.
|
|
51
|
+
* Apply this plugin to your User schema if you want to enable GitHub auth.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* import {githubUserPlugin} from "@terreno/api";
|
|
56
|
+
*
|
|
57
|
+
* userSchema.plugin(githubUserPlugin);
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function githubUserPlugin(schema: any) {
|
|
61
|
+
schema.add({
|
|
62
|
+
githubAvatarUrl: {type: String},
|
|
63
|
+
githubId: {index: true, sparse: true, type: String, unique: true},
|
|
64
|
+
githubProfileUrl: {type: String},
|
|
65
|
+
githubUsername: {type: String},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sets up GitHub OAuth authentication strategy.
|
|
71
|
+
* Call this after setupAuth() in your server initialization.
|
|
72
|
+
*/
|
|
73
|
+
export function setupGitHubAuth(
|
|
74
|
+
_app: express.Application,
|
|
75
|
+
userModel: UserModel,
|
|
76
|
+
githubOptions: GitHubAuthOptions
|
|
77
|
+
) {
|
|
78
|
+
const scope = githubOptions.scope ?? ["user:email"];
|
|
79
|
+
|
|
80
|
+
passport.use(
|
|
81
|
+
"github",
|
|
82
|
+
new GitHubStrategy(
|
|
83
|
+
{
|
|
84
|
+
callbackURL: githubOptions.callbackURL,
|
|
85
|
+
clientID: githubOptions.clientId,
|
|
86
|
+
clientSecret: githubOptions.clientSecret,
|
|
87
|
+
passReqToCallback: true,
|
|
88
|
+
scope,
|
|
89
|
+
},
|
|
90
|
+
async (
|
|
91
|
+
req: express.Request,
|
|
92
|
+
accessToken: string,
|
|
93
|
+
refreshToken: string,
|
|
94
|
+
profile: Profile,
|
|
95
|
+
done: (error: any, user?: any) => void
|
|
96
|
+
) => {
|
|
97
|
+
try {
|
|
98
|
+
const existingUser = req.user;
|
|
99
|
+
|
|
100
|
+
// If custom handler provided, use it
|
|
101
|
+
if (githubOptions.findOrCreateUser) {
|
|
102
|
+
const user = await githubOptions.findOrCreateUser(
|
|
103
|
+
profile,
|
|
104
|
+
accessToken,
|
|
105
|
+
refreshToken,
|
|
106
|
+
existingUser
|
|
107
|
+
);
|
|
108
|
+
return done(null, user);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Default implementation
|
|
112
|
+
const githubId = profile.id;
|
|
113
|
+
|
|
114
|
+
// Check if user with this GitHub ID already exists
|
|
115
|
+
const existingGitHubUser = await userModel.findOne({githubId} as any);
|
|
116
|
+
|
|
117
|
+
// Case 1: User is authenticated and wants to link GitHub account
|
|
118
|
+
if (existingUser) {
|
|
119
|
+
if (!githubOptions.allowAccountLinking) {
|
|
120
|
+
return done(new APIError({status: 400, title: "Account linking is disabled"}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
existingGitHubUser &&
|
|
125
|
+
existingGitHubUser._id.toString() !== existingUser._id.toString()
|
|
126
|
+
) {
|
|
127
|
+
return done(
|
|
128
|
+
new APIError({
|
|
129
|
+
status: 400,
|
|
130
|
+
title: "This GitHub account is already linked to another user",
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Link GitHub to existing user
|
|
136
|
+
const user = await userModel.findById(existingUser._id);
|
|
137
|
+
if (user) {
|
|
138
|
+
(user as any).githubId = githubId;
|
|
139
|
+
(user as any).githubUsername = profile.username;
|
|
140
|
+
(user as any).githubProfileUrl = profile.profileUrl;
|
|
141
|
+
(user as any).githubAvatarUrl = profile.photos?.[0]?.value;
|
|
142
|
+
await user.save();
|
|
143
|
+
return done(null, user);
|
|
144
|
+
}
|
|
145
|
+
return done(new APIError({status: 404, title: "User not found"}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Case 2: User with this GitHub ID exists - log them in
|
|
149
|
+
if (existingGitHubUser) {
|
|
150
|
+
return done(null, existingGitHubUser);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Case 3: Create new user with GitHub credentials
|
|
154
|
+
const email = profile.emails?.[0]?.value;
|
|
155
|
+
|
|
156
|
+
// Check if user with this email already exists
|
|
157
|
+
if (email) {
|
|
158
|
+
const existingEmailUser = await userModel.findOne({email} as any);
|
|
159
|
+
if (existingEmailUser) {
|
|
160
|
+
// If account linking is allowed, link GitHub to existing email account
|
|
161
|
+
if (githubOptions.allowAccountLinking !== false) {
|
|
162
|
+
(existingEmailUser as any).githubId = githubId;
|
|
163
|
+
(existingEmailUser as any).githubUsername = profile.username;
|
|
164
|
+
(existingEmailUser as any).githubProfileUrl = profile.profileUrl;
|
|
165
|
+
(existingEmailUser as any).githubAvatarUrl = profile.photos?.[0]?.value;
|
|
166
|
+
await existingEmailUser.save();
|
|
167
|
+
return done(null, existingEmailUser);
|
|
168
|
+
}
|
|
169
|
+
return done(
|
|
170
|
+
new APIError({
|
|
171
|
+
status: 400,
|
|
172
|
+
title:
|
|
173
|
+
"An account with this email already exists. Please log in and link your GitHub account.",
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Create new user
|
|
180
|
+
const newUser = new userModel({
|
|
181
|
+
admin: false,
|
|
182
|
+
email,
|
|
183
|
+
githubAvatarUrl: profile.photos?.[0]?.value,
|
|
184
|
+
githubId,
|
|
185
|
+
githubProfileUrl: profile.profileUrl,
|
|
186
|
+
githubUsername: profile.username,
|
|
187
|
+
} as any);
|
|
188
|
+
|
|
189
|
+
await newUser.save();
|
|
190
|
+
return done(null, newUser);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
logger.error(`GitHub auth error: ${error}`);
|
|
193
|
+
return done(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
) as passport.Strategy
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Adds GitHub OAuth routes to the Express application.
|
|
202
|
+
*
|
|
203
|
+
* Routes added:
|
|
204
|
+
* - GET /auth/github - Initiates GitHub OAuth flow
|
|
205
|
+
* - GET /auth/github/callback - Handles GitHub OAuth callback
|
|
206
|
+
* - POST /auth/github/link - Links GitHub account to authenticated user (requires JWT auth)
|
|
207
|
+
* - DELETE /auth/github/unlink - Unlinks GitHub account from authenticated user (requires JWT auth)
|
|
208
|
+
*/
|
|
209
|
+
export function addGitHubAuthRoutes(
|
|
210
|
+
app: express.Application,
|
|
211
|
+
userModel: UserModel,
|
|
212
|
+
githubOptions: GitHubAuthOptions,
|
|
213
|
+
authOptions?: AuthOptions
|
|
214
|
+
): void {
|
|
215
|
+
const router = require("express").Router();
|
|
216
|
+
|
|
217
|
+
// Initiate GitHub OAuth flow
|
|
218
|
+
router.get(
|
|
219
|
+
"/github",
|
|
220
|
+
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
221
|
+
// Store the return URL in session or query for redirect after auth
|
|
222
|
+
const returnTo = req.query.returnTo as string | undefined;
|
|
223
|
+
if (returnTo) {
|
|
224
|
+
(req as any).session = (req as any).session || {};
|
|
225
|
+
(req as any).session.returnTo = returnTo;
|
|
226
|
+
}
|
|
227
|
+
next();
|
|
228
|
+
},
|
|
229
|
+
passport.authenticate("github", {session: false})
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// GitHub OAuth callback
|
|
233
|
+
router.get(
|
|
234
|
+
"/github/callback",
|
|
235
|
+
passport.authenticate("github", {
|
|
236
|
+
failureRedirect: "/auth/github/failure",
|
|
237
|
+
session: false,
|
|
238
|
+
}),
|
|
239
|
+
async (req: express.Request, res: express.Response) => {
|
|
240
|
+
try {
|
|
241
|
+
const tokens = await generateTokens(req.user, authOptions);
|
|
242
|
+
const returnTo = (req as any).session?.returnTo;
|
|
243
|
+
|
|
244
|
+
// If there's a return URL, redirect with tokens as query params
|
|
245
|
+
if (returnTo) {
|
|
246
|
+
const url = new URL(returnTo);
|
|
247
|
+
url.searchParams.set("token", tokens.token || "");
|
|
248
|
+
if (tokens.refreshToken) {
|
|
249
|
+
url.searchParams.set("refreshToken", tokens.refreshToken);
|
|
250
|
+
}
|
|
251
|
+
url.searchParams.set("userId", (req.user as any)?._id?.toString() || "");
|
|
252
|
+
return res.redirect(url.toString());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Otherwise return JSON response
|
|
256
|
+
return res.json({
|
|
257
|
+
data: {
|
|
258
|
+
refreshToken: tokens.refreshToken,
|
|
259
|
+
token: tokens.token,
|
|
260
|
+
userId: (req.user as any)?._id,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
logger.error(`GitHub callback error: ${error}`);
|
|
265
|
+
return res.status(500).json({message: "Authentication failed"});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// GitHub auth failure handler
|
|
271
|
+
router.get("/github/failure", (_req: express.Request, res: express.Response) => {
|
|
272
|
+
return res.status(401).json({message: "GitHub authentication failed"});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Link GitHub to existing authenticated user
|
|
276
|
+
if (githubOptions.allowAccountLinking !== false) {
|
|
277
|
+
router.get(
|
|
278
|
+
"/github/link",
|
|
279
|
+
(req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
|
280
|
+
// Require JWT authentication for linking
|
|
281
|
+
passport.authenticate("jwt", {session: false}, (err: any, user: any) => {
|
|
282
|
+
if (err || !user) {
|
|
283
|
+
res.status(401).json({message: "Authentication required to link GitHub account"});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
req.user = user;
|
|
287
|
+
next();
|
|
288
|
+
})(req, res, next);
|
|
289
|
+
},
|
|
290
|
+
passport.authenticate("github", {session: false})
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Unlink GitHub from user account
|
|
294
|
+
router.delete(
|
|
295
|
+
"/github/unlink",
|
|
296
|
+
passport.authenticate("jwt", {session: false}),
|
|
297
|
+
async (req: express.Request, res: express.Response) => {
|
|
298
|
+
if (!req.user) {
|
|
299
|
+
return res.status(401).json({message: "Authentication required"});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Explicitly select hash and salt fields which may be hidden by default
|
|
304
|
+
const user = await userModel.findById((req.user as any)._id).select("+hash +salt");
|
|
305
|
+
if (!user) {
|
|
306
|
+
return res.status(404).json({message: "User not found"});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check if user has other authentication methods before unlinking
|
|
310
|
+
// passport-local-mongoose stores password in hash and salt fields
|
|
311
|
+
const hasPassword = !!(user as any).hash || !!(user as any).salt;
|
|
312
|
+
if (!hasPassword) {
|
|
313
|
+
return res.status(400).json({
|
|
314
|
+
message:
|
|
315
|
+
"Cannot unlink GitHub account without another authentication method. Set a password first.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
(user as any).githubId = undefined;
|
|
320
|
+
(user as any).githubUsername = undefined;
|
|
321
|
+
(user as any).githubProfileUrl = undefined;
|
|
322
|
+
(user as any).githubAvatarUrl = undefined;
|
|
323
|
+
await user.save();
|
|
324
|
+
|
|
325
|
+
return res.json({data: {message: "GitHub account unlinked successfully"}});
|
|
326
|
+
} catch (error) {
|
|
327
|
+
logger.error(`GitHub unlink error: ${error}`);
|
|
328
|
+
return res.status(500).json({message: "Failed to unlink GitHub account"});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
app.use("/auth", router);
|
|
335
|
+
}
|
package/src/index.ts
CHANGED
package/src/logger.ts
CHANGED
package/src/middleware.ts
CHANGED