Skip to content

Commit 17568c5

Browse files
committed
prevent CSRF attacks, consume verification token transactionally
- user's csrf cookie is sent as 'state' to the sync endpoint - 'state' is preserved in the DB, coupling it with the verification token --- verificationToken|csrfToken - consuming a verification token via POST requires the csrfToken - consuming a token also means deleting it from DB, a transaction is used to ensure this
1 parent 3b79145 commit 17568c5

File tree

1 file changed

+45
-29
lines changed

1 file changed

+45
-29
lines changed

pages/api/auth/sync.js

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ export default async function handler (req, res) {
1212
// POST /api/auth/sync
1313
// exchange a verification token for an ephemeral session token
1414
if (req.method === 'POST') {
15-
// a verification token is received from the middleware
16-
const { verificationToken } = req.body
17-
if (!verificationToken) {
18-
return res.status(400).json({ status: 'ERROR', reason: 'verification token is required' })
15+
// verification token and csrf token are received from the middleware
16+
const { verificationToken, csrfToken } = req.body
17+
if (!verificationToken || !csrfToken) {
18+
return res.status(400).json({ status: 'ERROR', reason: 'verification token and csrf token are required' })
1919
}
2020

2121
// validate and consume the verification token
22-
const validationResult = await consumeVerificationToken(verificationToken)
22+
const validationResult = await consumeVerificationToken(verificationToken, csrfToken)
2323
if (validationResult.status === 'ERROR') {
2424
return res.status(400).json(validationResult)
2525
}
@@ -40,10 +40,10 @@ export default async function handler (req, res) {
4040
// if there's a session, create a verification token and redirect to the domain
4141
if (req.method === 'GET') {
4242
// STEP 1: check if the domain is correct
43-
const { domain, redirectUri, signup } = req.query
43+
const { domain, state, signup, redirectUri } = req.query
4444
// domain and a path redirectUri are required
45-
if (!domain || !redirectUri?.startsWith('/')) {
46-
return res.status(400).json({ status: 'ERROR', reason: 'domain and a correct redirectUri are required' })
45+
if (!domain || !state || !redirectUri?.startsWith('/')) {
46+
return res.status(400).json({ status: 'ERROR', reason: 'domain, unique state and a correct redirectUri are required' })
4747
}
4848

4949
// STEP 2: check if domain is valid and ACTIVE
@@ -54,18 +54,18 @@ export default async function handler (req, res) {
5454

5555
// if we're signing up, redirect to the SN signup page and come back here
5656
if (signup) {
57-
return handleNoSession(res, domain, redirectUri, signup)
57+
return handleNoSession(res, domain, state, redirectUri, signup)
5858
}
5959

6060
// STEP 3: check if we have a session, if not, redirect to the SN login page
6161
const sessionToken = await getToken({ req }) // from cookie
6262
if (!sessionToken) {
6363
// we don't have a session, redirect to the login page and come back here
64-
return handleNoSession(res, domain, redirectUri)
64+
return handleNoSession(res, domain, state, redirectUri)
6565
}
6666

6767
// STEP 4: create a verification token
68-
const verificationToken = await createVerificationToken(sessionToken)
68+
const verificationToken = await createVerificationToken(sessionToken, state)
6969
if (verificationToken.status === 'ERROR') {
7070
return res.status(500).json(verificationToken)
7171
}
@@ -74,6 +74,7 @@ export default async function handler (req, res) {
7474
return redirectToDomain(res, domain, verificationToken.token, redirectUri)
7575
}
7676
} catch (error) {
77+
console.error('auth sync broke its legs', error)
7778
return res.status(500).json({ status: 'ERROR', reason: 'auth sync broke its legs' })
7879
}
7980
}
@@ -99,10 +100,12 @@ async function isDomainAllowed (domainName) {
99100
}
100101
}
101102

102-
function handleNoSession (res, domainName, redirectUri, signup = false) {
103+
function handleNoSession (res, domainName, state, redirectUri, signup = false) {
103104
// create the sync callback URL that we'll return to after login
104105
const syncUrl = new URL('/api/auth/sync', SN_MAIN_DOMAIN)
105106
syncUrl.searchParams.set('domain', domainName)
107+
// preserve the state from the original request
108+
syncUrl.searchParams.set('state', state)
106109
syncUrl.searchParams.set('redirectUri', redirectUri)
107110

108111
// create SN login URL and add our sync callback URL
@@ -114,13 +117,14 @@ function handleNoSession (res, domainName, redirectUri, signup = false) {
114117
res.redirect(302, loginRedirectUrl.href)
115118
}
116119

117-
async function createVerificationToken (token) {
120+
async function createVerificationToken (token, csrfToken) {
118121
try {
119122
// a 5 minutes verification token using the session token's user id
120123
const verificationToken = await models.verificationToken.create({
121124
data: {
122125
identifier: token.id.toString(),
123-
token: randomBytes(32).toString('hex'),
126+
// store csrf token with the verification token, to prevent CSRF attacks
127+
token: `${randomBytes(32).toString('hex')}|${csrfToken}`,
124128
expires: new Date(Date.now() + 1000 * 60 * 5) // 5 minutes
125129
}
126130
})
@@ -136,8 +140,9 @@ async function redirectToDomain (res, domainName, verificationToken, redirectUri
136140
const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
137141
const target = new URL(`${protocol}://${domainName}`)
138142

139-
// add the verification token and the redirectUri to the URL
140-
target.searchParams.set('token', verificationToken)
143+
// add the verification sync token and the redirectUri to the URL
144+
target.searchParams.set('synctoken', verificationToken.split('|')[0])
145+
target.searchParams.set('state', verificationToken.split('|')[1])
141146
target.searchParams.set('redirectUri', redirectUri)
142147

143148
// redirect to the custom domain
@@ -147,27 +152,38 @@ async function redirectToDomain (res, domainName, verificationToken, redirectUri
147152
}
148153
}
149154

150-
async function consumeVerificationToken (verificationToken) {
155+
async function consumeVerificationToken (verificationToken, csrfToken) {
156+
// sync tokens are stored as token|csrfToken
157+
const tokenWithState = `${verificationToken}|${csrfToken}`
151158
try {
152-
// find the verification token
153-
const { identifier } = await models.verificationToken.findFirst({
154-
where: {
155-
token: verificationToken,
156-
expires: { gt: new Date() }
159+
// find and delete the verification token
160+
const identifier = await models.$transaction(async tx => {
161+
const token = await tx.verificationToken.findFirst({
162+
where: {
163+
token: tokenWithState,
164+
expires: { gt: new Date() }
165+
}
166+
})
167+
168+
if (!token?.identifier) {
169+
return null
157170
}
171+
172+
// delete the verification token, we don't need it anymore
173+
await tx.verificationToken.delete({
174+
where: {
175+
token: tokenWithState
176+
}
177+
})
178+
179+
return token.identifier
158180
})
181+
159182
// if we can't find the verification token, it's invalid or expired
160183
if (!identifier) {
161184
return { status: 'ERROR', reason: 'invalid verification token' }
162185
}
163186

164-
// delete the verification token, we don't need it anymore
165-
await models.verificationToken.delete({
166-
where: {
167-
token: verificationToken
168-
}
169-
})
170-
171187
// return the user id
172188
return { status: 'OK', userId: Number(identifier) }
173189
} catch (error) {

0 commit comments

Comments
 (0)