@openneuro/server 4.35.0-alpha.1 → 4.36.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.35.0-alpha.1",
3
+ "version": "4.36.0-alpha.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-alpha.1",
24
+ "@openneuro/search": "^4.36.0-alpha.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": "ac9c013877e5d9c1fa9615a177f79e0da0747f84"
89
+ "gitHead": "0d754964177331113f4279c48d6c0fae0f59adc2"
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
  },
@@ -9,7 +9,7 @@ function isValidOrcid(orcid: string): boolean {
9
9
  export const user = (obj, { id }) => {
10
10
  if (isValidOrcid(id)) {
11
11
  return User.findOne({
12
- $or: [{ "orcid": id }, { "providerId": id }],
12
+ $or: [{ "provider": "orcid", "providerId": id }],
13
13
  }).exec()
14
14
  } else {
15
15
  // If it's not a valid ORCID, fall back to querying by user id
@@ -331,6 +331,7 @@ export const typeDefs = `
331
331
  location: String
332
332
  institution: String
333
333
  github: String
334
+ githubSynced: Date
334
335
  links: [String]
335
336
  }
336
337
 
@@ -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
+ }
@@ -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
  }
@@ -37,6 +37,8 @@ export interface UserDocument extends Document {
37
37
  github: string
38
38
  // User profile links
39
39
  links: string[]
40
+ avatar: string
41
+ githubSynced: Date
40
42
  }
41
43
 
42
44
  const userSchema = new Schema({
@@ -49,6 +51,7 @@ const userSchema = new Schema({
49
51
  google: String, // Google ID if this is an ORCID account with a Google account linked
50
52
  migrated: { type: Boolean, default: false },
51
53
  refresh: String,
54
+ avatar: String,
52
55
  admin: { type: Boolean, default: false },
53
56
  blocked: { type: Boolean, default: false },
54
57
  created: { type: Date, default: Date.now },
@@ -56,6 +59,7 @@ const userSchema = new Schema({
56
59
  location: { type: String, default: "" },
57
60
  institution: { type: String, default: "" },
58
61
  github: { type: String, default: "" },
62
+ githubSynced: { type: Date },
59
63
  links: { type: [String], default: [] },
60
64
  })
61
65
 
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",