@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 +6 -5
- package/src/config.ts +4 -0
- package/src/graphql/resolvers/__tests__/user.spec.ts +343 -18
- package/src/graphql/resolvers/comment.ts +1 -1
- package/src/graphql/resolvers/dataset.ts +1 -1
- package/src/graphql/resolvers/metadata.ts +1 -1
- package/src/graphql/resolvers/permissions.ts +4 -4
- package/src/graphql/resolvers/user.ts +117 -9
- package/src/graphql/schema.ts +20 -1
- package/src/libs/__tests__/github.spec.ts +33 -0
- package/src/libs/authentication/github.ts +185 -0
- package/src/libs/authentication/orcid.ts +15 -1
- package/src/libs/authentication/passport.ts +15 -9
- package/src/models/user.ts +10 -1
- package/src/routes.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/server",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
19
|
-
"@apollo/server": "4.
|
|
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.
|
|
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": "
|
|
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 {
|
|
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("
|
|
9
|
-
expect(
|
|
10
|
-
users
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
expect(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
10
|
+
export async function user(obj, { id }, { userInfo }) {
|
|
11
|
+
let user
|
|
10
12
|
if (isValidOrcid(id)) {
|
|
11
|
-
|
|
12
|
-
$or: [{ "
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
package/src/graphql/schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
}
|
package/src/models/user.ts
CHANGED
|
@@ -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",
|