@openneuro/server 4.35.0 → 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 +6 -5
- package/src/config.ts +4 -0
- package/src/graphql/resolvers/user.ts +1 -1
- package/src/graphql/schema.ts +1 -0
- package/src/libs/__tests__/github.spec.ts +33 -0
- package/src/libs/authentication/github.ts +185 -0
- package/src/libs/authentication/passport.ts +15 -9
- package/src/models/user.ts +4 -0
- 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-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.
|
|
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-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": "
|
|
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: [{ "
|
|
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
|
package/src/graphql/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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",
|