@openneuro/server 4.34.1 → 4.35.0-alpha.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.34.1",
3
+ "version": "4.35.0-alpha.0",
4
4
  "description": "Core service for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "src/server.js",
@@ -21,7 +21,7 @@
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.34.1",
24
+ "@openneuro/search": "^4.35.0-alpha.0",
25
25
  "@sentry/node": "^8.25.0",
26
26
  "@sentry/profiling-node": "^8.25.0",
27
27
  "base64url": "^3.0.0",
@@ -85,5 +85,5 @@
85
85
  "publishConfig": {
86
86
  "access": "public"
87
87
  },
88
- "gitHead": "af9d77970641f2dcfd67921a8c39b9719743676c"
88
+ "gitHead": "aef6b8ec1097cd7cd19ca9de82e17d8dfa87be71"
89
89
  }
@@ -192,7 +192,7 @@ export const participantCount = (obj, { modality }) => {
192
192
  matchQuery = {
193
193
  $expr: { $in: ["$id", nihDatasets] },
194
194
  }
195
- } else {
195
+ } else if (modality) {
196
196
  matchQuery = {
197
197
  $and: [
198
198
  queryHasSubjects,
@@ -1,7 +1,19 @@
1
1
  import passport from "passport"
2
+ import * as Sentry from "@sentry/node"
3
+ import User from "../../models/user"
4
+ import { PROVIDERS } from "./passport"
5
+ import { userMigration } from "./user-migration"
6
+
7
+ const loginErrorHandler = (next) => (loginErr) => {
8
+ if (loginErr) {
9
+ Sentry.captureException(loginErr)
10
+ return next(loginErr) // Pass error to Express error handler
11
+ }
12
+ return next()
13
+ }
2
14
 
3
15
  export const requestAuth = (req, res, next) =>
4
- passport.authenticate("google", {
16
+ passport.authenticate(PROVIDERS.GOOGLE, {
5
17
  scope: [
6
18
  "https://www.googleapis.com/auth/userinfo.email",
7
19
  "https://www.googleapis.com/auth/userinfo.profile",
@@ -12,7 +24,45 @@ export const requestAuth = (req, res, next) =>
12
24
  state: req.query.redirectPath || null,
13
25
  })(req, res, next)
14
26
 
15
- export const authCallback = passport.authenticate("google", {
16
- failureRedirect: "/",
17
- session: false,
18
- })
27
+ export const authCallback = async (req, res, next) => {
28
+ // Google auth and redirect to linking page
29
+ return passport.authenticate(
30
+ PROVIDERS.GOOGLE,
31
+ { session: false },
32
+ async (err, user, _info) => {
33
+ // First we check if an ORCID user exists for this login
34
+ let orcidUser
35
+ if (user?.orcid) {
36
+ orcidUser = await User.findOne({
37
+ providerId: user.orcid,
38
+ provider: PROVIDERS.ORCID,
39
+ })
40
+ }
41
+ if (orcidUser && user?.migrated) {
42
+ // User has ORCID linked already, redirect to login as linked account
43
+ return res.redirect("/crn/auth/orcid")
44
+ } else if (orcidUser && !(user?.migrated)) {
45
+ // Linked but not migrated, migrate here and then login
46
+ try {
47
+ await userMigration(orcidUser.providerId, user.id)
48
+ } catch (_err) {
49
+ // Already logged, just redirect to error page
50
+ return next(_err)
51
+ }
52
+ return res.redirect("/crn/auth/orcid")
53
+ } else {
54
+ if (err) {
55
+ Sentry.captureException(err)
56
+ // Redirect to a generic error page or handle specific errors if possible
57
+ return res.redirect("/error/google/unknown")
58
+ }
59
+ if (!user) {
60
+ // Authentication failed (e.g., user denied access)
61
+ return res.redirect("/")
62
+ }
63
+ req.query.state = Buffer.from("/orcid-link").toString("base64")
64
+ return req.logIn(user, { session: false }, loginErrorHandler(next))
65
+ }
66
+ },
67
+ )(req, res, next)
68
+ }
@@ -7,9 +7,9 @@ import config from "../../config"
7
7
 
8
8
  interface OpenNeuroTokenProfile {
9
9
  sub: string
10
- email: string
11
- provider: string
12
- name: string
10
+ email?: string
11
+ provider?: string
12
+ name?: string
13
13
  admin: boolean
14
14
  iat: number
15
15
  exp: number
@@ -1,7 +1,7 @@
1
1
  import passport from "passport"
2
- import User from "../../models/user"
3
2
  import { parsedJwtFromRequest } from "./jwt"
4
3
  import * as Sentry from "@sentry/node"
4
+ import { userMigration } from "./user-migration"
5
5
 
6
6
  export const requestAuth = passport.authenticate("orcid", {
7
7
  session: false,
@@ -20,19 +20,18 @@ export const authCallback = (req, res, next) =>
20
20
  if (!user) {
21
21
  return res.redirect("/")
22
22
  }
23
+ // Google user
23
24
  const existingAuth = parsedJwtFromRequest(req)
24
25
  if (existingAuth) {
25
- // Save ORCID to primary account
26
- User.findOne({ id: existingAuth.sub })
27
- .then((userModel) => {
28
- userModel.orcid = user.providerId
29
- return userModel.save().then(() => {
30
- res.redirect("/")
26
+ // Migrate Google to ORCID
27
+ if (existingAuth.provider === "google") {
28
+ return userMigration(user.providerId, existingAuth.sub).then(() => {
29
+ // Complete login with ORCID as primary account
30
+ req.logIn(user, { session: false }, (err) => {
31
+ return next(err)
31
32
  })
32
33
  })
33
- .catch((err) => {
34
- return next(err)
35
- })
34
+ }
36
35
  } else {
37
36
  // Complete login with ORCID as primary account
38
37
  req.logIn(user, { session: false }, (err) => {
@@ -9,7 +9,7 @@ import { encrypt } from "./crypto"
9
9
  import { addJWT, jwtFromRequest } from "./jwt"
10
10
  import orcid from "../orcid"
11
11
 
12
- const PROVIDERS = {
12
+ export const PROVIDERS = {
13
13
  GOOGLE: "google",
14
14
  ORCID: "orcid",
15
15
  }
@@ -0,0 +1,121 @@
1
+ import mongoose from "mongoose"
2
+ import User from "../../models/user"
3
+ import UserMigration from "../../models/userMigration"
4
+ import Dataset from "../../models/dataset"
5
+ import Permission from "../../models/permission"
6
+ import Comment from "../../models/comment"
7
+ import Deletion from "../../models/deletion"
8
+ import * as Sentry from "@sentry/node"
9
+
10
+ /**
11
+ * Move content from a Google account to an ORCID one
12
+ *
13
+ * Automatic rollback and error reporting if anything here fails
14
+ *
15
+ * Records the migration steps taken in the UserMigration model
16
+ *
17
+ * @param orcid ORCID iD of the user's primary account
18
+ * @param userId Account being merged with the ORCID account
19
+ */
20
+ export async function userMigration(orcid: string, userId: string) {
21
+ const session = await mongoose.startSession()
22
+ try {
23
+ await session.withTransaction(async () => {
24
+ try {
25
+ // Load both original records
26
+ const orcidUser = await User.findOne(
27
+ {
28
+ providerId: orcid,
29
+ provider: "orcid",
30
+ },
31
+ null,
32
+ { session },
33
+ )
34
+ const googleUser = await User.findOne(
35
+ {
36
+ id: userId,
37
+ provider: "google",
38
+ },
39
+ null,
40
+ { session },
41
+ )
42
+
43
+ // Save the original user records
44
+ const migration = new UserMigration({ session })
45
+ migration.users.push(orcidUser.toObject())
46
+ migration.users.push(googleUser.toObject())
47
+ await migration.save({ session })
48
+
49
+ // Migrate dataset ownership
50
+ const datasets = await Dataset.find({ uploader: googleUser.id }, null, {
51
+ session,
52
+ })
53
+ for (const dataset of datasets) {
54
+ dataset.uploader = orcidUser.id
55
+ // Record this dataset uploader as migrated
56
+ migration.datasets.push(dataset.id)
57
+ await dataset.save({ session })
58
+ }
59
+
60
+ // Migrate dataset permissions
61
+ const permissions = await Permission.find(
62
+ { userId: googleUser.id },
63
+ null,
64
+ {
65
+ session,
66
+ },
67
+ )
68
+ for (const permission of permissions) {
69
+ permission.userId = orcidUser.id
70
+ // Record this permission as migrated
71
+ migration.permissions.push(permission.toObject())
72
+ await permission.save({ session })
73
+ }
74
+
75
+ // Migrate dataset deletions
76
+ const deletions = await Deletion.find({ userId: googleUser.id }, null, {
77
+ session,
78
+ })
79
+ for (const deletion of deletions) {
80
+ deletion.user._id = orcidUser.id
81
+ // Record this deletion as migrated
82
+ migration.deletions.push(deletion.toObject())
83
+ await deletion.save({ session })
84
+ }
85
+
86
+ // Migrate comments
87
+ const comments = await Comment.find({ userId: googleUser.id }, null, {
88
+ session,
89
+ })
90
+ for (const comment of comments) {
91
+ comment.user._id = orcidUser.id
92
+ // Record this comment as migrated
93
+ migration.comments.push(comment.toObject())
94
+ await comment.save({ session })
95
+ }
96
+
97
+ // Migrate admin permissions if different
98
+ if (googleUser.admin) {
99
+ orcidUser.admin = true
100
+ }
101
+ // If either account is blocked should we even migrate?
102
+ if (googleUser.blocked) {
103
+ orcidUser.blocked = true
104
+ }
105
+ await orcidUser.save({ session })
106
+ // Save the orcid value that was actually used for future logins
107
+ googleUser.orcid = orcidUser.providerId
108
+ googleUser.migrated = true
109
+ await googleUser.save({ session })
110
+ // Save success
111
+ migration.success = true
112
+ await migration.save({ session })
113
+ } catch (err) {
114
+ Sentry.captureException(err)
115
+ throw err
116
+ }
117
+ })
118
+ } finally {
119
+ await session.endSession()
120
+ }
121
+ }
@@ -5,20 +5,37 @@ const { Schema, model } = mongoose
5
5
 
6
6
  export interface UserDocument extends Document {
7
7
  _id: string
8
+ // OpenNeuro specific user uuid
8
9
  id: string
10
+ // Best contact email for the user (notifications)
9
11
  email: string
12
+ // User's preferred name (visible)
10
13
  name: string
14
+ // Login provider
11
15
  provider: StaticRangeInit
16
+ // The id from the login provider
12
17
  providerId: string
18
+ // ORCID iD associated with this OpenNeuro user
13
19
  orcid: string
20
+ // Google account id associated with this OpenNeuro user
21
+ google: string
22
+ // Is this a migrated account? Migrated accounts were Google accounts moved to ORCID and disabled
23
+ migrated: boolean
14
24
  refresh: string
25
+ // Is this user a site admin with permissions for all datasets?
15
26
  admin: boolean
27
+ // Has this user been banned from the site?
16
28
  blocked: boolean
29
+ // Original account creation time
17
30
  created: Date
31
+ // Last time the user authenticated
18
32
  lastSeen: Date
19
33
  location: string
34
+ // Institution populated from ORCID
20
35
  institution: string
36
+ // GitHub account linked
21
37
  github: string
38
+ // User profile links
22
39
  links: string[]
23
40
  }
24
41
 
@@ -29,6 +46,8 @@ const userSchema = new Schema({
29
46
  provider: String, // Login provider
30
47
  providerId: String, // Login provider unique id
31
48
  orcid: String, // ORCID iD regardless of provider id
49
+ google: String, // Google ID if this is an ORCID account with a Google account linked
50
+ migrated: { type: Boolean, default: false },
32
51
  refresh: String,
33
52
  admin: { type: Boolean, default: false },
34
53
  blocked: { type: Boolean, default: false },
@@ -0,0 +1,32 @@
1
+ import { v4 as uuidv4 } from "uuid"
2
+ import mongoose from "mongoose"
3
+ import type { Document } from "mongoose"
4
+ const { Schema, model } = mongoose
5
+
6
+ export interface UserMigrationDocument extends Document {
7
+ _id: string
8
+ orcid: string
9
+ google: string
10
+ users: object[]
11
+ datasets: string[]
12
+ permissions: object[]
13
+ comments: object[]
14
+ deletions: object[]
15
+ success: boolean
16
+ }
17
+
18
+ const userMigrationSchema = new Schema({
19
+ id: { type: String, default: uuidv4 }, // OpenNeuro id
20
+ orcid: String,
21
+ google: String,
22
+ datasets: { type: [String], default: [] },
23
+ permissions: { type: [Object], default: [] },
24
+ comments: { type: [String], default: [] },
25
+ deletions: { type: [String], default: [] },
26
+ users: { type: [Object], default: [] },
27
+ success: Boolean,
28
+ })
29
+
30
+ const User = model<UserMigrationDocument>("UserMigration", userMigrationSchema)
31
+
32
+ export default User
package/src/routes.ts CHANGED
@@ -178,6 +178,7 @@ const router = express.Router()
178
178
 
179
179
  for (const route of routes) {
180
180
  const arr = Object.hasOwn(route, "middleware") ? route.middleware : []
181
+ // @ts-expect-error This is actually working.
181
182
  arr.unshift(route.url)
182
183
  arr.push(route.handler)
183
184
  router[route.method](...arr)