@openneuro/server 4.35.0 → 4.36.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/server",
3
- "version": "4.35.0",
3
+ "version": "4.36.0",
4
4
  "description": "Core service for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "src/server.js",
@@ -15,13 +15,13 @@
15
15
  },
16
16
  "author": "Squishymedia",
17
17
  "dependencies": {
18
- "@apollo/client": "3.11.8",
19
- "@apollo/server": "4.9.3",
18
+ "@apollo/client": "3.13.8",
19
+ "@apollo/server": "4.12.1",
20
20
  "@apollo/utils.keyvadapter": "3.0.0",
21
21
  "@elastic/elasticsearch": "8.13.1",
22
22
  "@graphql-tools/schema": "^10.0.0",
23
23
  "@keyv/redis": "^2.7.0",
24
- "@openneuro/search": "^4.35.0",
24
+ "@openneuro/search": "^4.36.0",
25
25
  "@sentry/node": "^8.25.0",
26
26
  "@sentry/profiling-node": "^8.25.0",
27
27
  "base64url": "^3.0.0",
@@ -48,6 +48,7 @@
48
48
  "node-mailjet": "^3.3.5",
49
49
  "object-hash": "2.1.1",
50
50
  "passport": "0.7.0",
51
+ "passport-github2": "^0.1.12",
51
52
  "passport-google-oauth20": "2.0.0",
52
53
  "passport-jwt": "4.0.1",
53
54
  "passport-oauth2-refresh": "2.2.0",
@@ -85,5 +86,5 @@
85
86
  "publishConfig": {
86
87
  "access": "public"
87
88
  },
88
- "gitHead": "ce3f98fbbe9da0c9463dcae9fe6357c1000d0fa5"
89
+ "gitHead": "ac7fa1782779cfdf1f46c9a5add8865155177b99"
89
90
  }
package/src/config.ts CHANGED
@@ -27,6 +27,10 @@ const config = {
27
27
  clientID: process.env.GLOBUS_CLIENT_ID,
28
28
  clientSecret: process.env.GLOBUS_CLIENT_SECRET,
29
29
  },
30
+ github: {
31
+ clientID: process.env.GITHUB_CLIENT_ID,
32
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
33
+ },
30
34
  jwt: {
31
35
  secret: process.env.JWT_SECRET,
32
36
  },
@@ -1,27 +1,352 @@
1
- import { vi } from "vitest"
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ } from "vitest"
10
+ import { MongoMemoryServer } from "mongodb-memory-server"
11
+ import mongoose, { Types } from "mongoose"
12
+ import User from "../../../models/user"
2
13
  import { users } from "../user.js"
14
+ import type { GraphQLContext } from "../user.js"
3
15
 
4
16
  vi.mock("ioredis")
5
17
 
18
+ let mongod: MongoMemoryServer
19
+
20
+ // Test data loaded before each test
21
+ const testUsersSeedData = [
22
+ {
23
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f1"),
24
+ id: "u1",
25
+ email: "atest1@example.com",
26
+ name: "Alice Admin",
27
+ admin: true,
28
+ blocked: false,
29
+ migrated: false,
30
+ updatedAt: new Date("2023-01-05T00:00:00.000Z"),
31
+ providerId: "0000-0000-0000-0001",
32
+ provider: "orcid",
33
+ orcid: "0000-0000-0000-0001",
34
+ },
35
+ {
36
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f2"),
37
+ id: "u2",
38
+ email: "btest2@example.com",
39
+ name: "Test User",
40
+ admin: false,
41
+ blocked: false,
42
+ migrated: false,
43
+ updatedAt: new Date("2023-01-04T00:00:00.000Z"),
44
+ providerId: "google2",
45
+ provider: "google",
46
+ },
47
+ {
48
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f3"),
49
+ id: "u3",
50
+ email: "ctest3@example.com",
51
+ name: "Test User",
52
+ admin: false,
53
+ blocked: true,
54
+ migrated: false,
55
+ updatedAt: new Date("2023-01-03T00:00:00.000Z"),
56
+ providerId: "0000-0000-0000-0003",
57
+ provider: "orcid",
58
+ },
59
+ {
60
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f4"),
61
+ id: "u4",
62
+ email: "dtest4@example.com",
63
+ name: "Test Admin User",
64
+ admin: true,
65
+ blocked: true,
66
+ migrated: false,
67
+ updatedAt: new Date("2023-01-02T00:00:00.000Z"),
68
+ providerId: "0000-0000-0000-0004",
69
+ provider: "orcid",
70
+ orcid: "0000-0000-0000-0004",
71
+ },
72
+ {
73
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f5"),
74
+ id: "u5",
75
+ email: "etest2@example.com",
76
+ name: "Test User",
77
+ admin: false,
78
+ blocked: false,
79
+ migrated: true,
80
+ updatedAt: new Date("2023-01-04T00:00:00.000Z"),
81
+ providerId: "google2",
82
+ provider: "google",
83
+ },
84
+ {
85
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f6"),
86
+ id: "u6",
87
+ email: "ftest6@example.com",
88
+ name: "Test Admin User",
89
+ admin: true,
90
+ blocked: false,
91
+ migrated: false,
92
+ updatedAt: new Date("2023-01-06T00:00:00.000Z"),
93
+ providerId: "orcid6",
94
+ provider: "orcid",
95
+ orcid: "0000-0000-0000-0006",
96
+ }, // Most recent
97
+ {
98
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f7"),
99
+ id: "u7",
100
+ email: "gtest7@example.com",
101
+ name: "Test User",
102
+ admin: false,
103
+ blocked: false,
104
+ migrated: false,
105
+ updatedAt: new Date("2023-01-05T00:00:00.000Z"),
106
+ }, // Same updatedAt as u1, for secondary sort testing
107
+ ]
108
+
109
+ // Admin context for tests
110
+ const adminContext: GraphQLContext = {
111
+ userInfo: { userId: "admin-user", admin: true, username: "adminUser" },
112
+ }
113
+ // Non-admin context for tests
114
+ const nonAdminContext: GraphQLContext = {
115
+ userInfo: { userId: "normal-user", admin: false, username: "normalUser" },
116
+ }
117
+
6
118
  describe("user resolvers", () => {
119
+ beforeAll(async () => {
120
+ mongod = await MongoMemoryServer.create()
121
+ const uri = mongod.getUri()
122
+ await mongoose.connect(uri)
123
+ })
124
+
125
+ afterAll(async () => {
126
+ await mongoose.disconnect()
127
+ await mongod.stop()
128
+ })
129
+
130
+ beforeEach(async () => {
131
+ await User.deleteMany({})
132
+ await User.insertMany(testUsersSeedData)
133
+ })
134
+
7
135
  describe("users()", () => {
8
- it("throws an error for non-admins", () => {
9
- expect(
10
- users(
11
- null,
12
- { id: "3311cfe8-9764-434d-b80e-1b1ee72c686d" },
13
- { userInfo: {} },
14
- ),
15
- ).rejects.toEqual(new Error("You must be a site admin to retrieve users"))
16
- })
17
- it("throws an error for non-admins", () => {
18
- expect(
19
- users(
20
- null,
21
- { id: "0000-0000-0000-000X" },
22
- { userInfo: {} },
23
- ),
24
- ).rejects.toEqual(new Error("You must be a site admin to retrieve users"))
136
+ it("rejects data for non-admin context", async () => {
137
+ await expect(users(null, {}, nonAdminContext)).rejects.toThrow(
138
+ "You must be a site admin to retrieve users",
139
+ )
140
+ })
141
+
142
+ it("returns all non-migrated users by default, sorted by updatedAt desc then _id desc", async () => {
143
+ const result = await users(null, {}, adminContext)
144
+ // u5 is migrated. 6 non-migrated users: u1, u2, u3, u4, u6, u7
145
+ expect(result.users.length).toBe(6)
146
+ expect(result.totalCount).toBe(6)
147
+
148
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
149
+ .sort({ updatedAt: -1, _id: -1 })
150
+ .exec()
151
+ const expectedIds = dbSortedUsers.map((u) => u.id)
152
+ expect(result.users.map((u) => u.id)).toEqual(expectedIds)
153
+ // Expected order based on seed data _ids: u6, u7, u1, u2, u3, u4
154
+ // (u7 _id > u1 _id, so u7 comes before u1 in _id desc sort when updatedAt is same)
155
+ expect(expectedIds).toEqual(["u6", "u7", "u1", "u2", "u3", "u4"])
156
+ })
157
+
158
+ it("handles pagination with limit", async () => {
159
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
160
+ .sort({ updatedAt: -1, _id: -1 })
161
+ .exec()
162
+ const expectedLimitedIds = dbSortedUsers.slice(0, 2).map((u) => u.id)
163
+
164
+ const result = await users(null, { limit: 2 }, adminContext)
165
+ expect(result.users.length).toBe(2)
166
+ expect(result.totalCount).toBe(6)
167
+ expect(result.users.map((u) => u.id)).toEqual(expectedLimitedIds)
168
+ })
169
+
170
+ it("handles pagination with offset", async () => {
171
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
172
+ .sort({ updatedAt: -1, _id: -1 })
173
+ .exec()
174
+ const expectedOffsetIds = dbSortedUsers.slice(4).map((u) => u.id) // Skips 4
175
+
176
+ const result = await users(null, { offset: 4 }, adminContext)
177
+ expect(result.users.length).toBe(2) // Total 6, offset 4, so 2 remaining
178
+ expect(result.totalCount).toBe(6)
179
+ expect(result.users.map((u) => u.id)).toEqual(expectedOffsetIds) // Should be ['u3', 'u4']
180
+ })
181
+
182
+ it("handles pagination with limit and offset", async () => {
183
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
184
+ .sort({ updatedAt: -1, _id: -1 })
185
+ .exec()
186
+ const expectedPaginatedIds = dbSortedUsers.slice(1, 1 + 2).map((u) =>
187
+ u.id
188
+ ) // Skip 1, take 2
189
+
190
+ const result = await users(null, { limit: 2, offset: 1 }, adminContext)
191
+ expect(result.users.length).toBe(2)
192
+ expect(result.totalCount).toBe(6)
193
+ expect(result.users.map((u) => u.id)).toEqual(expectedPaginatedIds) // Should be ['u7', 'u1']
194
+ })
195
+
196
+ it("filters by isAdmin: true", async () => {
197
+ const result = await users(null, { isAdmin: true }, adminContext)
198
+ expect(result.users.length).toBe(3)
199
+ expect(result.totalCount).toBe(3)
200
+ const ids = result.users.map((u) => u.id)
201
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u4", "u6"]))
202
+ })
203
+
204
+ it("filters by isAdmin: false", async () => {
205
+ const result = await users(null, { isAdmin: false }, adminContext)
206
+ expect(result.users.length).toBe(3)
207
+ expect(result.totalCount).toBe(3)
208
+ const ids = result.users.map((u) => u.id)
209
+ expect(ids).toEqual(expect.arrayContaining(["u2", "u3", "u7"]))
210
+ })
211
+
212
+ it("filters by isBlocked: true", async () => {
213
+ const result = await users(null, { isBlocked: true }, adminContext)
214
+ expect(result.users.length).toBe(2)
215
+ expect(result.totalCount).toBe(2)
216
+ const ids = result.users.map((u) => u.id)
217
+ expect(ids).toEqual(expect.arrayContaining(["u3", "u4"]))
218
+ })
219
+
220
+ it("filters by isBlocked: false", async () => {
221
+ const result = await users(null, { isBlocked: false }, adminContext)
222
+ // Not blocked (non-migrated): u1, u2, u6, u7 -> 4 users
223
+ expect(result.users.length).toBe(4)
224
+ expect(result.totalCount).toBe(4)
225
+ const ids = result.users.map((u) => u.id)
226
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u2", "u6", "u7"]))
227
+ })
228
+
229
+ it("searches by name (case-insensitive, partial)", async () => {
230
+ const result = await users(null, { search: "Alice" }, adminContext)
231
+ expect(result.users.length).toBe(1)
232
+ expect(result.totalCount).toBe(1)
233
+ expect(result.users[0].id).toBe("u1")
234
+ })
235
+
236
+ it("searches by email (case-insensitive, partial)", async () => {
237
+ const result = await users(
238
+ null,
239
+ { search: "btest2@EXAMPLE" },
240
+ adminContext,
241
+ )
242
+ expect(result.users.length).toBe(1)
243
+ expect(result.totalCount).toBe(1)
244
+ expect(result.users[0].id).toBe("u2")
245
+ })
246
+
247
+ it("search returns empty if no match", async () => {
248
+ const result = await users(null, { search: "NoSuchUser" }, adminContext)
249
+ expect(result.users.length).toBe(0)
250
+ expect(result.totalCount).toBe(0)
251
+ })
252
+
253
+ it("combines search with other filters (e.g., isAdmin)", async () => {
254
+ await User.create({
255
+ _id: new Types.ObjectId(),
256
+ id: "u8",
257
+ email: "search_admin@example.com",
258
+ name: "Searchable Admin",
259
+ admin: true,
260
+ blocked: false,
261
+ migrated: false,
262
+ updatedAt: new Date("2023-01-07T00:00:00.000Z"),
263
+ })
264
+ // Search for "Admin" (matches u1 name, u6 name, u8 name) and isAdmin: true
265
+ const result = await users(
266
+ null,
267
+ { search: "Admin", isAdmin: true },
268
+ adminContext,
269
+ )
270
+ expect(result.users.length).toBe(4) // u1, u6, u8
271
+ expect(result.totalCount).toBe(4)
272
+ const ids = result.users.map((u) => u.id)
273
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u6", "u8"]))
274
+ })
275
+
276
+ it("sorts by name ascending (with _id ascending as secondary)", async () => {
277
+ const result = await users(null, {
278
+ orderBy: [{ field: "name", order: "ascending" }],
279
+ }, adminContext)
280
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
281
+ .sort({ name: 1, _id: 1 })
282
+ .exec()
283
+ expect(result.users.map((u) => u.id)).toEqual(
284
+ dbSortedUsers.map((u) => u.id),
285
+ )
286
+ // Expected: u1, u4, u6, u2, u3, u7
287
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
288
+ "u1",
289
+ "u4",
290
+ "u6",
291
+ "u2",
292
+ "u3",
293
+ "u7",
294
+ ])
295
+ })
296
+
297
+ it("sorts by email descending (with _id descending as secondary)", async () => {
298
+ const result = await users(null, {
299
+ orderBy: [{ field: "email", order: "descending" }],
300
+ }, adminContext)
301
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
302
+ .sort({ email: -1, _id: -1 })
303
+ .exec()
304
+ expect(result.users.map((u) => u.id)).toEqual(
305
+ dbSortedUsers.map((u) => u.id),
306
+ )
307
+ // Expected: u7, u6, u4, u3, u2, u1
308
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
309
+ "u7",
310
+ "u6",
311
+ "u4",
312
+ "u3",
313
+ "u2",
314
+ "u1",
315
+ ])
316
+ })
317
+
318
+ it("handles multiple sort fields specified in orderBy (e.g. admin asc, name asc)", async () => {
319
+ const result = await users(null, {
320
+ orderBy: [
321
+ { field: "admin", order: "ascending" },
322
+ { field: "name", order: "ascending" },
323
+ ],
324
+ }, adminContext)
325
+ // Mongoose sort: { admin: 1, name: 1, _id: 1 } (since _id is added based on first sort field's order)
326
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
327
+ .sort({ admin: 1, name: 1, _id: 1 })
328
+ .exec()
329
+ expect(result.users.map((u) => u.id)).toEqual(
330
+ dbSortedUsers.map((u) => u.id),
331
+ )
332
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
333
+ "u2",
334
+ "u3",
335
+ "u7",
336
+ "u1",
337
+ "u4",
338
+ "u6",
339
+ ])
340
+ })
341
+
342
+ it("correctly calculates totalCount regardless of pagination", async () => {
343
+ const result = await users(
344
+ null,
345
+ { isAdmin: true, limit: 1 },
346
+ adminContext,
347
+ )
348
+ expect(result.users.length).toBe(1)
349
+ expect(result.totalCount).toBe(3) // u1, u4, u6 are admins
25
350
  })
26
351
  })
27
352
  })
@@ -105,7 +105,7 @@ export const deleteComment = async (
105
105
  const CommentFields = {
106
106
  parent: (obj) => comment(obj, { id: obj.parentId }),
107
107
  replies,
108
- user: (obj) => user(obj, { id: obj.user._id }),
108
+ user: (obj) => user(obj, { id: obj.user._id }, null),
109
109
  }
110
110
 
111
111
  export default CommentFields
@@ -278,7 +278,7 @@ const worker = (obj) => getDatasetWorker(obj.id)
278
278
  * Dataset object
279
279
  */
280
280
  const Dataset = {
281
- uploader: (ds) => user(ds, { id: ds.uploader }),
281
+ uploader: (ds, _, context) => user(ds, { id: ds.uploader }, context),
282
282
  draft: async (obj) => {
283
283
  const draftHead = await datalad.getDraftHead(obj.id)
284
284
  return {
@@ -27,7 +27,7 @@ export const metadata = async (
27
27
  // TODO - This could be a user object that is resolved with the full type instead of just email
28
28
  // Email matches the existing records however and the user object would require other changes
29
29
  const adminUsers = []
30
- const { userPermissions } = await permissions(dataset)
30
+ const { userPermissions } = await permissions(dataset, null, context)
31
31
  for (const user of userPermissions) {
32
32
  if (user.level === "admin") {
33
33
  const userObj = await user.user
@@ -11,14 +11,14 @@ interface DatasetPermission {
11
11
  userPermissions: (PermissionDocument & { user: Promise<UserDocument> })[]
12
12
  }
13
13
 
14
- export async function permissions(ds): Promise<DatasetPermission> {
14
+ export async function permissions(ds, _, context): Promise<DatasetPermission> {
15
15
  const permissions = await Permission.find({ datasetId: ds.id }).exec()
16
16
  return {
17
17
  id: ds.id,
18
18
  userPermissions: permissions.map(
19
19
  (userPermission) => ({
20
20
  ...userPermission.toJSON(),
21
- user: user(ds, { id: userPermission.userId }),
21
+ user: user(ds, { id: userPermission.userId }, context),
22
22
  } as unknown as PermissionDocument & { user: Promise<UserDocument> }),
23
23
  ),
24
24
  }
@@ -28,13 +28,13 @@ const publishPermissions = async (datasetId) => {
28
28
  // Create permissionsUpdated object with DatasetPermissions in Dataset
29
29
  // and resolve all promises before publishing
30
30
  const ds = { id: datasetId }
31
- const { id, userPermissions } = await permissions(ds)
31
+ const { id, userPermissions } = await permissions(ds, null, null)
32
32
  const permissionsUpdated = {
33
33
  id,
34
34
  userPermissions: await Promise.all(
35
35
  userPermissions.map(async (userPermission) => ({
36
36
  ...userPermission,
37
- user: await user(ds, { id: userPermission.userId }),
37
+ user: await user(ds, { id: userPermission.userId }, null),
38
38
  })),
39
39
  ),
40
40
  }
@@ -2,29 +2,137 @@
2
2
  * User resolvers
3
3
  */
4
4
  import User from "../../models/user"
5
+
5
6
  function isValidOrcid(orcid: string): boolean {
6
7
  return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "")
7
8
  }
8
9
 
9
- export const user = (obj, { id }) => {
10
+ export async function user(obj, { id }, { userInfo }) {
11
+ let user
10
12
  if (isValidOrcid(id)) {
11
- return User.findOne({
12
- $or: [{ "orcid": id }, { "providerId": id }],
13
+ user = await User.findOne({
14
+ $or: [{ "provider": "orcid", "providerId": id }],
13
15
  }).exec()
14
16
  } else {
15
17
  // If it's not a valid ORCID, fall back to querying by user id
16
- return User.findOne({ "id": id }).exec()
18
+ user = await User.findOne({ "id": id }).exec()
19
+ }
20
+ if (userInfo?.admin || user.id === userInfo?.id) {
21
+ return user.toObject()
22
+ } else {
23
+ const obj = user.toObject()
24
+ delete obj.email
25
+ return obj
17
26
  }
18
27
  }
19
28
 
20
- export const users = (obj, args, { userInfo }) => {
21
- if (userInfo.admin) {
22
- return User.find().exec()
23
- } else {
29
+ export interface UserInfo {
30
+ userId: string
31
+ admin: boolean
32
+ username?: string
33
+ provider?: string
34
+ providerId?: string
35
+ blocked?: boolean
36
+ }
37
+
38
+ export interface GraphQLContext {
39
+ userInfo: UserInfo | null
40
+ }
41
+
42
+ type MongoOperatorValue =
43
+ | string
44
+ | number
45
+ | boolean
46
+ | RegExp
47
+ | (string | number | boolean | RegExp)[]
48
+
49
+ type MongoQueryOperator<T> = T | {
50
+ $ne?: T
51
+ $regex?: string
52
+ $options?: string
53
+ $gt?: T
54
+ $gte?: T
55
+ $lt?: T
56
+ $lte?: T
57
+ $in?: T[]
58
+ }
59
+
60
+ type MongoFilterValue =
61
+ | MongoOperatorValue
62
+ | MongoQueryOperator<MongoOperatorValue>
63
+
64
+ interface MongoQueryCondition {
65
+ [key: string]: MongoFilterValue
66
+ }
67
+ export const users = async (
68
+ obj: unknown,
69
+ { isAdmin, isBlocked, search, limit = 100, offset = 0, orderBy }: {
70
+ isAdmin?: boolean
71
+ isBlocked?: boolean
72
+ search?: string
73
+ limit?: number
74
+ offset?: number
75
+ orderBy?: { field: string; order?: "ascending" | "descending" }[]
76
+ },
77
+ context: GraphQLContext,
78
+ ) => {
79
+ // --- check admin ---
80
+ if (!context.userInfo?.admin) {
24
81
  return Promise.reject(
25
82
  new Error("You must be a site admin to retrieve users"),
26
83
  )
27
84
  }
85
+
86
+ const filter: {
87
+ admin?: MongoQueryOperator<boolean>
88
+ blocked?: MongoQueryOperator<boolean>
89
+ migrated?: MongoQueryOperator<boolean>
90
+ $or?: MongoQueryCondition[]
91
+ name?: MongoQueryOperator<string | RegExp>
92
+ email?: MongoQueryOperator<string | RegExp>
93
+ } = {}
94
+
95
+ if (isAdmin !== undefined) filter.admin = isAdmin
96
+ if (isBlocked !== undefined) filter.blocked = isBlocked
97
+
98
+ filter.migrated = { $ne: true }
99
+
100
+ if (search) {
101
+ filter.$or = [
102
+ { name: { $regex: search, $options: "i" } },
103
+ { email: { $regex: search, $options: "i" } },
104
+ ]
105
+ }
106
+
107
+ let sort: Record<string, "asc" | "desc"> = {}
108
+ if (orderBy && orderBy.length > 0) {
109
+ orderBy.forEach((sortRule) => {
110
+ sort[sortRule.field] = sortRule.order
111
+ ? sortRule.order === "ascending" ? "asc" : "desc"
112
+ : "asc"
113
+ })
114
+
115
+ if (!sort._id) {
116
+ const primarySortOrder = sort[orderBy[0].field] || "asc"
117
+ sort._id = primarySortOrder
118
+ }
119
+ } else {
120
+ sort = { updatedAt: "desc", _id: "desc" }
121
+ }
122
+
123
+ const totalCount = await User.countDocuments(filter).exec()
124
+
125
+ let query = User.find(filter)
126
+ if (offset !== undefined) query = query.skip(offset)
127
+ if (limit !== undefined) query = query.limit(limit)
128
+ query = query.sort(sort)
129
+
130
+ const users = await query.exec()
131
+
132
+ return {
133
+ users: users,
134
+ totalCount: totalCount,
135
+ }
28
136
  }
29
137
 
30
138
  export const removeUser = (obj, { id }, { userInfo }) => {
@@ -89,7 +197,6 @@ const UserResolvers = {
89
197
  avatar: (obj) => obj.avatar,
90
198
  orcid: (obj) => obj.orcid,
91
199
  created: (obj) => obj.created,
92
- modified: (obj) => obj.modified,
93
200
  lastSeen: (obj) => obj.lastSeen,
94
201
  email: (obj) => obj.email,
95
202
  name: (obj) => obj.name,
@@ -98,6 +205,7 @@ const UserResolvers = {
98
205
  location: (obj) => obj.location,
99
206
  institution: (obj) => obj.institution,
100
207
  links: (obj) => obj.links,
208
+ modified: (obj) => obj.updatedAt,
101
209
  }
102
210
 
103
211
  export default UserResolvers
@@ -26,6 +26,12 @@ export const typeDefs = `
26
26
  descending
27
27
  }
28
28
 
29
+ # Sorting order for users
30
+ input UserSortInput {
31
+ field: String!
32
+ order: SortOrdering = ascending
33
+ }
34
+
29
35
  # Sorting order for datasets
30
36
  input DatasetSort {
31
37
  # Dataset created time
@@ -83,7 +89,14 @@ export const typeDefs = `
83
89
  # Get one user
84
90
  user(id: ID!): User
85
91
  # Get a list of users
86
- users: [User]
92
+ users(
93
+ orderBy: [UserSortInput!]
94
+ isAdmin: Boolean
95
+ isBlocked: Boolean
96
+ search: String
97
+ limit: Int
98
+ offset: Int
99
+ ): UserList!
87
100
  # Get the total number of dataset participants
88
101
  participantCount(modality: String): Int @cacheControl(maxAge: 3600, scope: PUBLIC)
89
102
  # Request one snapshot
@@ -331,9 +344,15 @@ export const typeDefs = `
331
344
  location: String
332
345
  institution: String
333
346
  github: String
347
+ githubSynced: Date
334
348
  links: [String]
335
349
  }
336
350
 
351
+ type UserList {
352
+ users: [User!]!
353
+ totalCount: Int!
354
+ }
355
+
337
356
  # Which provider a user login comes from
338
357
  enum UserProvider {
339
358
  google
@@ -0,0 +1,33 @@
1
+ import type { Strategy as GitHubStrategy } from "passport-github2"
2
+ import { vi } from "vitest"
3
+ import passport from "passport"
4
+ import { setupGitHubAuth } from "../authentication/github"
5
+
6
+ // Mock the necessary modules
7
+ vi.mock("../../models/user")
8
+ vi.mock("../../config", () => ({
9
+ default: {
10
+ auth: {
11
+ github: {
12
+ clientID: "test-client-id",
13
+ clientSecret: "test-client-secret",
14
+ },
15
+ jwt: {
16
+ secret: "test-jwt-secret",
17
+ },
18
+ },
19
+ url: "http://localhost",
20
+ apiPrefix: "/api/",
21
+ },
22
+ }))
23
+
24
+ describe("GitHub OAuth Strategy", () => {
25
+ beforeAll(() => {
26
+ setupGitHubAuth()
27
+ })
28
+
29
+ it("should initialize GitHub strategy", () => {
30
+ const strategy = passport._strategies.github as GitHubStrategy
31
+ expect(strategy).toBeDefined()
32
+ })
33
+ })
@@ -0,0 +1,185 @@
1
+ import passport from "passport"
2
+ import jwt from "jsonwebtoken"
3
+ import { Strategy as GitHubStrategy } from "passport-github2"
4
+ import config from "../../config"
5
+ import User from "../../models/user"
6
+ import * as Sentry from "@sentry/node"
7
+ import { addJWT, jwtFromRequest } from "./jwt"
8
+ import type { NextFunction, Request, Response } from "express"
9
+
10
+ interface GitHubAuthInfo {
11
+ message?: string
12
+ }
13
+
14
+ interface GitHubUser {
15
+ _id: string
16
+ id: string
17
+ email?: string
18
+ name?: string
19
+ github?: string
20
+ avatar?: string
21
+ location?: string
22
+ institution?: string
23
+ links?: string[]
24
+ githubSynced?: Date
25
+ }
26
+
27
+ // Middleware to store last visited page before authentication
28
+ export const storeRedirect = (
29
+ req: Request,
30
+ res: Response,
31
+ next: NextFunction,
32
+ ) => {
33
+ req.query.redirectTo = (req.get("Referer") || "/") as string
34
+ next()
35
+ }
36
+
37
+ export const setupGitHubAuth = () => {
38
+ if (!config.auth.github.clientID || !config.auth.github.clientSecret) return
39
+
40
+ const githubStrategy = new GitHubStrategy(
41
+ {
42
+ clientID: config.auth.github.clientID,
43
+ clientSecret: config.auth.github.clientSecret,
44
+ callbackURL: `${config.url + config.apiPrefix}auth/github/callback`,
45
+ scope: ["user:email", "read:user"],
46
+ passReqToCallback: true,
47
+ },
48
+ async (req, accessToken, refreshToken, profile, done) => {
49
+ try {
50
+ const token = jwtFromRequest(req)
51
+ const decoded = token ? jwt.verify(token, config.auth.jwt.secret) : null
52
+
53
+ if (!decoded || !decoded.sub) {
54
+ if (req.res) {
55
+ req.res.redirect("/error/github")
56
+ }
57
+ return done(new Error("No authenticated user found"), null)
58
+ }
59
+
60
+ const currentUser = await User.findOne({
61
+ id: decoded.sub,
62
+ })
63
+
64
+ if (!currentUser) {
65
+ if (req.res) {
66
+ req.res.redirect("/error/github")
67
+ }
68
+ return done(null, false, {
69
+ message: "Please login with your ORCID account",
70
+ })
71
+ }
72
+
73
+ // Updating GitHub info
74
+ currentUser.github = profile.username
75
+ currentUser.avatar = profile._json.avatar_url
76
+
77
+ if (!currentUser.location && profile._json.location) {
78
+ currentUser.location = profile._json.location
79
+ }
80
+ if (!currentUser.institution && profile._json.company) {
81
+ currentUser.institution = profile._json.company
82
+ }
83
+
84
+ // Ensure links array exists before adding GitHub profile URL
85
+ if (!currentUser.links) {
86
+ currentUser.links = []
87
+ }
88
+ if (
89
+ profile.profileUrl && !currentUser.links.includes(profile.profileUrl)
90
+ ) {
91
+ currentUser.links.push(profile.profileUrl)
92
+ }
93
+
94
+ currentUser.githubSynced = new Date()
95
+ await currentUser.save()
96
+ return done(null, addJWT(config)(currentUser.toObject()), {
97
+ message: "GitHub sync successful",
98
+ })
99
+ } catch (err) {
100
+ Sentry.captureException(err)
101
+ return done(err, null)
102
+ }
103
+ },
104
+ )
105
+
106
+ passport.use("github", githubStrategy)
107
+ }
108
+
109
+ const getStringFromQuery = (
110
+ query:
111
+ | string
112
+ | string[]
113
+ | Record<string, string | string[] | undefined>
114
+ | undefined,
115
+ ): string | undefined => {
116
+ if (typeof query === "string") {
117
+ return query
118
+ }
119
+ if (Array.isArray(query) && query.length > 0) {
120
+ return query[0]
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ export const requestAuth = (
126
+ req: Request,
127
+ res: Response,
128
+ next: NextFunction,
129
+ ) => {
130
+ const redirectToParam = req.query.redirectTo as string | undefined // Explicit cast
131
+ const redirectToQuery: string | undefined = getStringFromQuery(
132
+ redirectToParam,
133
+ )
134
+ const redirectTo: string = redirectToQuery || "/"
135
+ passport.authenticate("github", {
136
+ state: encodeURIComponent(redirectTo),
137
+ })(req, res, next)
138
+ }
139
+
140
+ export const authCallback = (
141
+ req: Request,
142
+ res: Response,
143
+ next: NextFunction,
144
+ ) => {
145
+ passport.authenticate(
146
+ "github",
147
+ (err: Error | null, user: GitHubUser | false, info: GitHubAuthInfo) => {
148
+ const stateParam = req.query.state as string | undefined // Explicit cast
149
+ const stateQuery: string | undefined = getStringFromQuery(stateParam)
150
+ const redirectTo: string = stateQuery
151
+ ? decodeURIComponent(stateQuery)
152
+ : "/"
153
+
154
+ // Remove any existing query parameters
155
+ const cleanRedirectTo = redirectTo.split("?")[0]
156
+
157
+ if (err) {
158
+ Sentry.captureException(err)
159
+ return res.redirect(
160
+ `${cleanRedirectTo}?error=${
161
+ encodeURIComponent(
162
+ String(err?.message || "github_auth_failed"),
163
+ )
164
+ }`,
165
+ )
166
+ }
167
+
168
+ if (!user) {
169
+ Sentry.captureMessage(
170
+ `GitHub Auth Failed - Info: ${JSON.stringify(info)}`,
171
+ "warning",
172
+ )
173
+ return res.redirect(
174
+ `${cleanRedirectTo}?error=${
175
+ encodeURIComponent(
176
+ info?.message || "github_auth_failed",
177
+ )
178
+ }`,
179
+ )
180
+ }
181
+
182
+ return res.redirect(`${cleanRedirectTo}?success=github_auth_success`)
183
+ },
184
+ )(req, res, next)
185
+ }
@@ -2,6 +2,7 @@ import passport from "passport"
2
2
  import { parsedJwtFromRequest } from "./jwt"
3
3
  import * as Sentry from "@sentry/node"
4
4
  import { userMigration } from "./user-migration"
5
+ import User from "../../models/user"
5
6
 
6
7
  export const requestAuth = passport.authenticate("orcid", {
7
8
  session: false,
@@ -29,7 +30,7 @@ export function completeRequestLogin(req, res, next, user) {
29
30
  }
30
31
 
31
32
  export const authCallback = (req, res, next) =>
32
- passport.authenticate("orcid", (err, user) => {
33
+ passport.authenticate("orcid", async (err, user) => {
33
34
  if (err) {
34
35
  Sentry.captureException(err)
35
36
  if (err.type) {
@@ -41,6 +42,19 @@ export const authCallback = (req, res, next) =>
41
42
  if (!user) {
42
43
  return res.redirect("/")
43
44
  }
45
+
46
+ try {
47
+ // adds new date for login/lastSeen
48
+ await User.findByIdAndUpdate(user._id, { lastSeen: new Date() })
49
+ } catch (error: unknown) {
50
+ if (error instanceof Error) {
51
+ Sentry.captureException(error)
52
+ } else {
53
+ Sentry.captureException(new Error(String(error)))
54
+ }
55
+ // Don't block the login flow
56
+ }
57
+
44
58
  // Google user
45
59
  const existingAuth = parsedJwtFromRequest(req)
46
60
  if (existingAuth) {
@@ -8,10 +8,12 @@ import User from "../../models/user"
8
8
  import { encrypt } from "./crypto"
9
9
  import { addJWT, jwtFromRequest } from "./jwt"
10
10
  import orcid from "../orcid"
11
+ import { setupGitHubAuth } from "./github"
11
12
 
12
13
  export const PROVIDERS = {
13
14
  GOOGLE: "google",
14
15
  ORCID: "orcid",
16
+ GITHUB: "github",
15
17
  }
16
18
 
17
19
  interface OauthProfile {
@@ -21,6 +23,7 @@ interface OauthProfile {
21
23
  providerId: string
22
24
  orcid?: string
23
25
  refresh?: string
26
+ avatar?: string
24
27
  }
25
28
 
26
29
  export const loadProfile = (profile): OauthProfile | Error => {
@@ -45,6 +48,13 @@ export const loadProfile = (profile): OauthProfile | Error => {
45
48
  orcid: profile.orcid,
46
49
  refresh: undefined,
47
50
  }
51
+ } else if (profile.provider === PROVIDERS.GITHUB) {
52
+ return {
53
+ email: profile.emails ? profile.emails[0].value : "",
54
+ name: profile.displayName || profile.username,
55
+ provider: profile.provider,
56
+ providerId: profile.id,
57
+ }
48
58
  } else {
49
59
  // Some unknown profile type
50
60
  return new Error("Unhandled profile type.")
@@ -110,11 +120,7 @@ export const setupPassportAuth = () => {
110
120
  { secretOrKey: config.auth.jwt.secret, jwtFromRequest },
111
121
  (jwt, done) => {
112
122
  if (jwt.scopes?.includes("dataset:indexing")) {
113
- done(null, {
114
- admin: false,
115
- blocked: false,
116
- indexer: true,
117
- })
123
+ done(null, { admin: false, blocked: false, indexer: true })
118
124
  } else if (jwt.scopes?.includes("dataset:reviewer")) {
119
125
  done(null, {
120
126
  admin: false,
@@ -125,10 +131,9 @@ export const setupPassportAuth = () => {
125
131
  } else {
126
132
  // A user must already exist to use a JWT to auth a request
127
133
  User.findOne({ id: jwt.sub, provider: jwt.provider })
128
- .then((user) => {
129
- if (user) done(null, user.toObject())
130
- else done(null, false)
131
- })
134
+ .then((user) =>
135
+ user ? done(null, user.toObject()) : done(null, false)
136
+ )
132
137
  .catch(done)
133
138
  }
134
139
  },
@@ -168,4 +173,5 @@ export const setupPassportAuth = () => {
168
173
  )
169
174
  passport.use(PROVIDERS.ORCID, orcidStrategy)
170
175
  }
176
+ setupGitHubAuth()
171
177
  }
@@ -30,6 +30,7 @@ export interface UserDocument extends Document {
30
30
  created: Date
31
31
  // Last time the user authenticated
32
32
  lastSeen: Date
33
+ // Location populated from User or Github
33
34
  location: string
34
35
  // Institution populated from ORCID
35
36
  institution: string
@@ -37,6 +38,12 @@ export interface UserDocument extends Document {
37
38
  github: string
38
39
  // User profile links
39
40
  links: string[]
41
+ // Added for Mongoose timestamps
42
+ updatedAt: Date
43
+ // Avatar populated from Github
44
+ avatar: string
45
+ // githubSynced populated from Github OAuth use
46
+ githubSynced: Date
40
47
  }
41
48
 
42
49
  const userSchema = new Schema({
@@ -49,6 +56,7 @@ const userSchema = new Schema({
49
56
  google: String, // Google ID if this is an ORCID account with a Google account linked
50
57
  migrated: { type: Boolean, default: false },
51
58
  refresh: String,
59
+ avatar: String,
52
60
  admin: { type: Boolean, default: false },
53
61
  blocked: { type: Boolean, default: false },
54
62
  created: { type: Date, default: Date.now },
@@ -56,8 +64,9 @@ const userSchema = new Schema({
56
64
  location: { type: String, default: "" },
57
65
  institution: { type: String, default: "" },
58
66
  github: { type: String, default: "" },
67
+ githubSynced: { type: Date },
59
68
  links: { type: [String], default: [] },
60
- })
69
+ }, { timestamps: { createdAt: false, updatedAt: true } })
61
70
 
62
71
  userSchema.index({ id: 1, provider: 1 }, { unique: true })
63
72
  // Allow case-insensitive email queries
package/src/routes.ts CHANGED
@@ -9,11 +9,13 @@ import * as subscriptions from "./handlers/subscriptions"
9
9
  import verifyUser from "./libs/authentication/verifyUser"
10
10
  import * as google from "./libs/authentication/google"
11
11
  import * as orcid from "./libs/authentication/orcid"
12
+ import * as githubAuth from "./libs/authentication/github"
12
13
  import * as jwt from "./libs/authentication/jwt"
13
14
  import * as auth from "./libs/authentication/states"
14
15
  import * as doi from "./handlers/doi"
15
16
  import { sitemapHandler } from "./handlers/sitemap"
16
17
  import { reviewerHandler } from "./handlers/reviewer"
18
+ import { storeRedirect } from "./libs/authentication/github"
17
19
 
18
20
  const noCache = (req, res, next) => {
19
21
  res.setHeader("Surrogate-Control", "no-store")
@@ -157,6 +159,21 @@ const routes = [
157
159
  middleware: [noCache, orcid.authCallback],
158
160
  handler: jwt.authSuccessHandler,
159
161
  },
162
+
163
+ // GitHub authentication route
164
+ {
165
+ method: "get",
166
+ url: "/auth/github",
167
+ middleware: [storeRedirect],
168
+ handler: githubAuth.requestAuth,
169
+ },
170
+
171
+ {
172
+ method: "get",
173
+ url: "/auth/github/callback",
174
+ handler: githubAuth.authCallback,
175
+ },
176
+
160
177
  // Anonymous reviewer access
161
178
  {
162
179
  method: "get",