-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
/
index.js
315 lines (287 loc) · 12.3 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
import { createHash, randomBytes } from 'crypto'
import jwt from '../lib/jwt'
import parseUrl from '../lib/parse-url'
import cookie from './lib/cookie'
import callbackUrlHandler from './lib/callback-url-handler'
import parseProviders from './lib/providers'
import events from './lib/events'
import callbacks from './lib/callbacks'
import providers from './routes/providers'
import signin from './routes/signin'
import signout from './routes/signout'
import callback from './routes/callback'
import session from './routes/session'
import pages from './pages'
import adapters from '../adapters'
import logger from '../lib/logger'
// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
if (!process.env.NEXTAUTH_URL) {
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
export default async (req, res, userSuppliedOptions) => {
// To the best of my knowledge, we need to return a promise here
// to avoid early termination of calls to the serverless function
// (and then return that promise when we are done) - eslint
// complains but I'm not sure there is another way to do this.
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
// This is passed to all methods that handle responses, and must be called
// when they are complete so that the serverless function knows when it is
// safe to return and that no more data will be sent.
const done = resolve
const { url, query, body } = req
const {
nextauth,
action = nextauth[0],
provider = nextauth[1],
error = nextauth[1]
} = query
const {
csrfToken: csrfTokenFromPost
} = body
// @todo refactor all existing references to site, baseUrl and basePath
const parsedUrl = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
const baseUrl = parsedUrl.baseUrl
const basePath = parsedUrl.basePath
// Parse database / adapter
let adapter
if (userSuppliedOptions.adapter) {
// If adapter is provided, use it (advanced usage, overrides database)
adapter = userSuppliedOptions.adapter
} else if (userSuppliedOptions.database) {
// If database URI or config object is provided, use it (simple usage)
adapter = adapters.Default(userSuppliedOptions.database)
}
// Secret used salt cookies and tokens (e.g. for CSRF protection).
// If no secret option is specified then it creates one on the fly
// based on options passed here. A options contains unique data, such as
// oAuth provider secrets and database credentials it should be sufficent.
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({ baseUrl, basePath, ...userSuppliedOptions })).digest('hex')
// Use secure cookies if the site uses HTTPS
// This being conditional allows cookies to work non-HTTPS development URLs
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
// prefix, but enable them by default if the site URL is HTTPS; but not for
// non-HTTPS URLs like http://localhost which are used in development).
// For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
const useSecureCookies = userSuppliedOptions.useSecureCookies || baseUrl.startsWith('https://')
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
// @TODO Review cookie settings (names, options)
const cookies = {
// default cookie options
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
// Allow user cookie options to override any cookie settings above
...userSuppliedOptions.cookies
}
// Session options
const sessionOptions = {
jwt: false,
maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
...userSuppliedOptions.session
}
// JWT options
const jwtOptions = {
secret, // Use application secret if no keys specified
maxAge: sessionOptions.maxAge, // maxAge is dereived from session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userSuppliedOptions.jwt
}
// If no adapter specified, force use of JSON Web Tokens (stateless)
if (!adapter) {
sessionOptions.jwt = true
}
// Event messages
const eventsOptions = {
...events,
...userSuppliedOptions.events
}
// Callback functions
const callbacksOptions = {
...callbacks,
...userSuppliedOptions.callbacks
}
// Ensure CSRF Token cookie is set for any subsequent requests.
// Used as part of the strateigy for mitigation for CSRF tokens.
//
// Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
// where 'token' is the CSRF token and 'hash' is a hash made of the token and
// the secret, and the two values are joined by a pipe '|'. By storing the
// value and the hash of the value (with the secret used as a salt) we can
// verify the cookie was set by the server and not by a malicous attacker.
//
// For more details, see the following OWASP links:
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
// https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
let csrfToken
let csrfTokenVerified = false
if (req.cookies[cookies.csrfToken.name]) {
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
// If hash matches then we trust the CSRF token value
csrfToken = csrfTokenValue
// If this is a POST request and the CSRF Token in the Post request matches
// the cookie we have already verified is one we have set, then token is verified!
if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true }
}
}
if (!csrfToken) {
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
// (e.g. because it's been modifed or because the secret has changed) create a new token.
csrfToken = randomBytes(32).toString('hex')
const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}`
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
}
// Helper method for handling redirects, this is passed to all routes
// @TODO Refactor into a lib instead of passing as an option
// e.g. and call as redirect(req, res, url)
const redirect = (redirectUrl) => {
const reponseAsJson = !!((req.body && req.body.json === 'true'))
if (reponseAsJson) {
res.json({ url: redirectUrl })
} else {
res.status(302).setHeader('Location', redirectUrl)
res.end()
}
return done()
}
// User provided options are overriden by other options,
// except for the options with special handling above
const options = {
// Defaults options can be overidden
debug: false, // Enable debug messages to be displayed
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
// Custom options override defaults
...userSuppliedOptions,
// These computed settings can values in userSuppliedOptions but override them
// and are request-specific.
adapter,
baseUrl,
basePath,
action,
provider,
cookies,
secret,
csrfToken,
providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath),
session: sessionOptions,
jwt: jwtOptions,
events: eventsOptions,
callbacks: callbacksOptions,
callbackUrl: baseUrl,
redirect
}
// If debug enabled, set ENV VAR so that logger logs debug messages
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }
// Get / Set callback URL based on query param / cookie + validation
options.callbackUrl = await callbackUrlHandler(req, res, options)
if (req.method === 'GET') {
switch (action) {
case 'providers':
providers(req, res, options, done)
break
case 'session':
session(req, res, options, done)
break
case 'csrf':
res.json({ csrfToken })
return done()
case 'signin':
if (options.pages.signIn) {
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
return redirect(redirectUrl)
}
pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
break
case 'signout':
if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) }
pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done)
break
case 'callback':
if (provider && options.providers[provider]) {
callback(req, res, options, done)
} else {
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
return done()
}
break
case 'verify-request':
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
pages.render(req, res, 'verify-request', { baseUrl }, done)
break
case 'error':
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
pages.render(req, res, 'error', { baseUrl, basePath, error }, done)
break
default:
res.status(404).end()
return done()
}
} else if (req.method === 'POST') {
switch (action) {
case 'signin':
// Verified CSRF Token required for all sign in routes
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
if (provider && options.providers[provider]) {
signin(req, res, options, done)
}
break
case 'signout':
// Verified CSRF Token required for signout
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signout?csrf=true`)
}
signout(req, res, options, done)
break
case 'callback':
if (provider && options.providers[provider]) {
// Verified CSRF Token required for credentials providers only
if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
callback(req, res, options, done)
} else {
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
}
break
default:
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
}
} else {
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
return done()
}
})
}