Skip to content

Commit 46a23cc

Browse files
committedMay 29, 2025
Auth Sync Rewrite - Sync API endpoint and Middleware
Middleware: - redirects to auth sync API endpoint - supports signup redirects - if it receives a verification token, sends a POST to auth sync API endpoint and exchange it for a JWT session token - applies session token from the POST - gets rid of next auth functions GET Auth Sync: - issue a verification token tied to the session token's user id - support signup and redirect accordingly - use getToken from Next-Auth to get the live session token - also validate domain param through custom domain schema validation before checking if ACTIVE POST Auth Sync: - consume the verification token by checking its existence in DB and deleting it afterwards - create and return ephemeral JWT session token
1 parent 716f982 commit 46a23cc

File tree

2 files changed

+177
-96
lines changed

2 files changed

+177
-96
lines changed
 

‎middleware.js

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { NextResponse, URLPattern } from 'next/server'
22
import { getDomainMapping } from '@/lib/domains'
33
import { SESSION_COOKIE, cookieOptions } from '@/lib/auth'
4-
import { decode as decodeJWT } from 'next-auth/jwt'
54

65
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
76
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
@@ -34,18 +33,16 @@ async function customDomainMiddleware (request, domain, subName) {
3433
console.log('[domains] pathname', pathname) // TEST
3534
console.log('[domains] searchParams', searchParams) // TEST
3635

37-
// WIP Rewrite Auth Sync
36+
// Auth Sync
37+
// if the user is trying to login or signup, redirect to the Auth Sync API
3838
if (pathname.startsWith('/login') || pathname.startsWith('/signup')) {
39-
return authSyncMiddleware(url, domain)
40-
}
41-
42-
// WIP Rewrite Auth Sync
43-
if (searchParams.has('token')) {
44-
const redirectUri = searchParams.get('redirectUri') || '/'
45-
const res = NextResponse.redirect(decodeURIComponent(redirectUri))
46-
return establishAuthSync(res, url, domain)
39+
const signup = pathname.startsWith('/signup')
40+
return redirectToAuthSync(searchParams, domain, signup)
4741
}
42+
// if we have a verification token, exchange it for a session token
43+
if (searchParams.has('token')) return establishAuthSync(searchParams)
4844

45+
// Territory URLs
4946
// if sub param exists and doesn't match the domain's subname, update it
5047
if (searchParams.has('sub') && searchParams.get('sub') !== subName) {
5148
console.log('[domains] setting sub to', subName) // TEST
@@ -75,11 +72,16 @@ async function customDomainMiddleware (request, domain, subName) {
7572
return NextResponse.next({ request: { headers } })
7673
}
7774

78-
// WIP Rewrite Auth Sync
79-
async function authSyncMiddleware (url, domain) {
80-
const { searchParams } = url
75+
// redirect to the Auth Sync API
76+
async function redirectToAuthSync (searchParams, domain, signup) {
8177
const syncUrl = new URL('/api/auth/sync', SN_MAIN_DOMAIN)
8278
syncUrl.searchParams.set('domain', domain)
79+
80+
// if we're signing up, we need to set the signup flag
81+
if (signup) {
82+
syncUrl.searchParams.set('signup', 'true')
83+
}
84+
8385
// if we have a callbackUrl, we need to set it as redirectUri
8486
if (searchParams.has('callbackUrl')) {
8587
syncUrl.searchParams.set('redirectUri', searchParams.get('callbackUrl'))
@@ -88,33 +90,38 @@ async function authSyncMiddleware (url, domain) {
8890
return NextResponse.redirect(syncUrl)
8991
}
9092

91-
async function establishAuthSync (res, url, domain) {
92-
const { searchParams } = url
93+
// POST to /api/auth/sync and set the session cookie
94+
async function establishAuthSync (searchParams) {
95+
// get the verification token from the search params
9396
const token = searchParams.get('token')
97+
// get the redirectUri from the search params
98+
const redirectUri = searchParams.get('redirectUri') || '/'
99+
// prepare redirect to the redirectUri
100+
const res = NextResponse.redirect(decodeURIComponent(redirectUri))
101+
102+
// POST to /api/auth/sync to exchange verification token for session token
103+
const response = await fetch(`${SN_MAIN_DOMAIN.origin}/api/auth/sync`, {
104+
method: 'POST',
105+
headers: {
106+
'Content-Type': 'application/json'
107+
},
108+
body: JSON.stringify({
109+
verificationToken: token
110+
})
111+
})
94112

95-
const decodedSession = await verifySessionToken(token, domain)
96-
if (!decodedSession) {
97-
// TODO: maybe a page with a message?
113+
// get the session token from the response
114+
const data = await response.json()
115+
if (data.status === 'ERROR') {
116+
// if the response is an error, redirect to the home page
98117
return NextResponse.redirect('/')
99118
}
119+
100120
// set the session cookie
101-
res.cookies.set(SESSION_COOKIE, token, cookieOptions())
121+
res.cookies.set(SESSION_COOKIE, data.sessionToken, cookieOptions())
102122
return res
103123
}
104124

105-
async function verifySessionToken (sessionToken, domain) {
106-
const decodedSession = await decodeJWT({
107-
token: sessionToken,
108-
secret: process.env.NEXTAUTH_SECRET
109-
})
110-
// check if the session is valid and belongs to the domain
111-
if (!decodedSession || decodedSession?.domainName !== domain) {
112-
console.log('[establishAuthSync] invalid session token') // TEST
113-
return false
114-
}
115-
return decodedSession
116-
}
117-
118125
function getContentReferrer (request, url) {
119126
if (itemPattern.test(url)) {
120127
let id = request.nextUrl.searchParams.get('commentId')

‎pages/api/auth/sync.js

Lines changed: 138 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,78 @@
1-
// SYNC sketchbook
2-
// WIP
3-
4-
import { getServerSession } from 'next-auth/next'
5-
import { getAuthOptions } from './[...nextauth]'
1+
// Auth Sync API
62
import models from '@/api/models'
7-
import { encode as encodeJWT } from 'next-auth/jwt'
3+
import { randomBytes } from 'node:crypto'
4+
import { encode as encodeJWT, getToken } from 'next-auth/jwt'
85
import { validateSchema, customDomainSchema } from '@/lib/validate'
96

107
const SN_MAIN_DOMAIN = new URL(process.env.NEXT_PUBLIC_URL)
118
const SYNC_TOKEN_MAX_AGE = 60 // 1 minute
129

1310
export default async function handler (req, res) {
1411
try {
15-
// STEP 1: check if the domain is correct
16-
const { domain, redirectUri = '/' } = req.query
17-
if (!domain) {
18-
return res.status(400).json({ status: 'ERROR', reason: 'domain is a required parameter' })
19-
}
20-
21-
// prepare domain
22-
const domainName = domain.toLowerCase().trim()
23-
24-
// STEP 2: check if domain is valid and ACTIVE
25-
const domainValidation = await isDomainAllowed(domainName)
26-
if (domainValidation.status === 'ERROR') {
27-
return res.status(400).json(domainValidation)
12+
// POST /api/auth/sync
13+
// exchange a verification token for an ephemeral session token
14+
if (req.method === 'POST') {
15+
// a verification token is received from the middleware
16+
const { verificationToken } = JSON.parse(req.body)
17+
if (!verificationToken) {
18+
return res.status(400).json({ status: 'ERROR', reason: 'verification token is required' })
19+
}
20+
21+
// validate and consume the verification token
22+
const validationResult = await consumeVerificationToken(verificationToken)
23+
if (validationResult.status === 'ERROR') {
24+
return res.status(400).json(validationResult)
25+
}
26+
27+
// create a short-lived JWT session token with the user id
28+
const sessionTokenResult = await createEphemeralSessionToken(validationResult.userId)
29+
if (sessionTokenResult.status === 'ERROR') {
30+
// if we can't create a session token, return the error
31+
return res.status(500).json(sessionTokenResult)
32+
}
33+
34+
// return the session token
35+
return res.status(200).json({ status: 'OK', sessionToken: sessionTokenResult.sessionToken })
2836
}
2937

30-
// STEP 3: check if we have a session, if not, redirect to the SN login page
31-
const session = await getServerSession(req, res, getAuthOptions(req, res))
32-
if (!session?.user) {
33-
return handleNoSession(res, domainName, redirectUri)
38+
// GET /api/auth/sync
39+
// check if there's a session, if not, redirect to the SN login page and come back here
40+
// if there's a session, create a verification token and redirect to the domain
41+
if (req.method === 'GET') {
42+
// STEP 1: check if the domain is correct
43+
const { domain, redirectUri = '/' } = req.query
44+
// 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' })
47+
}
48+
49+
// STEP 2: check if domain is valid and ACTIVE
50+
const domainValidation = await isDomainAllowed(domain)
51+
if (domainValidation.status === 'ERROR') {
52+
return res.status(400).json(domainValidation)
53+
}
54+
55+
// if we're signing up, redirect to the SN signup page and come back here
56+
if (req.headers.get('x-stacker-news-signup') === 'true') {
57+
return handleNoSession(res, domain, redirectUri, true)
58+
}
59+
60+
// STEP 3: check if we have a session, if not, redirect to the SN login page
61+
const sessionToken = await getToken({ req }) // from cookie
62+
if (!sessionToken) {
63+
// we don't have a session, redirect to the login page and come back here
64+
return handleNoSession(res, domain, redirectUri)
65+
}
66+
67+
// STEP 4: create a verification token
68+
const verificationToken = await createVerificationToken(sessionToken)
69+
if (verificationToken.status === 'ERROR') {
70+
return res.status(500).json(verificationToken)
71+
}
72+
73+
// STEP 5: redirect to the domain with the verification token
74+
return redirectToDomain(res, domain, verificationToken.token, redirectUri)
3475
}
35-
36-
// STEP 4: create an ephemeral session and redirect to the custom domain
37-
const sessionToken = await createEphemeralSessionToken(session, domainName)
38-
if (sessionToken.status === 'ERROR') {
39-
return res.status(500).json(sessionToken)
40-
}
41-
42-
return redirectToDomain(res, domainName, sessionToken, redirectUri)
4376
} catch (error) {
4477
return res.status(500).json({ status: 'ERROR', reason: 'auth sync broke its legs' })
4578
}
@@ -51,67 +84,108 @@ async function isDomainAllowed (domainName) {
5184
// check if domain is conformative
5285
await validateSchema(customDomainSchema, { domainName })
5386
// check if domain is ACTIVE
54-
// not cached because we're handling sensitive data
55-
const domainInfo = await models.domain.findUnique({
87+
const domain = await models.domain.findUnique({
5688
where: { domainName, status: 'ACTIVE' }
5789
})
58-
if (!domainInfo) {
90+
91+
if (!domain) {
5992
return { status: 'ERROR', reason: 'domain not allowed' }
6093
}
61-
return { status: 'OK', domain: domainInfo }
94+
95+
// domain is valid and ACTIVE
96+
return { status: 'OK' }
6297
} catch (error) {
6398
return { status: 'ERROR', reason: 'domain is not valid' }
6499
}
65100
}
66101

67-
function handleNoSession (res, domainName, redirectUri) {
68-
// if we don't have a session, redirect to the login page
69-
const loginUrl = new URL('/login', SN_MAIN_DOMAIN)
70-
71-
// sync url as callback to continue syncing afterwards
72-
// sync url: /api/auth/sync?domain=www.pizza.com&redirectUri=/
102+
function handleNoSession (res, domainName, redirectUri, signup) {
103+
// create the sync callback URL that we'll return to after login
73104
const syncUrl = new URL('/api/auth/sync', SN_MAIN_DOMAIN)
74105
syncUrl.searchParams.set('domain', domainName)
75106
syncUrl.searchParams.set('redirectUri', redirectUri)
76107

77-
// set callbackUrl as syncUrl
78-
loginUrl.searchParams.set('callbackUrl', syncUrl.href)
79-
res.redirect(302, loginUrl.href)
108+
// create SN login URL and add our sync callback URL
109+
const loginRedirectUrl = new URL(signup ? '/signup' : '/login', SN_MAIN_DOMAIN)
110+
loginRedirectUrl.searchParams.set('callbackUrl', syncUrl.href)
111+
112+
// redirect user to login page
113+
res.redirect(302, loginRedirectUrl.href)
80114
}
81115

82-
// creates an ephemeral session token for the user
83-
async function createEphemeralSessionToken (session, domainName) {
116+
async function createVerificationToken (token) {
84117
try {
85-
const domainTiedPayload = {
86-
...session.user,
87-
domainName,
88-
purpose: 'auth_sync'
89-
}
90-
const sessionToken = await encodeJWT({
91-
token: domainTiedPayload,
92-
secret: process.env.NEXTAUTH_SECRET,
93-
maxAge: SYNC_TOKEN_MAX_AGE
118+
// a 5 minutes verification token using the session token's user id
119+
const verificationToken = await models.verificationToken.create({
120+
data: {
121+
identifier: token.id.toString(),
122+
token: randomBytes(32).toString('hex'),
123+
expires: new Date(Date.now() + 1000 * 60 * 5) // 5 minutes
124+
}
94125
})
95-
return sessionToken
126+
return { status: 'OK', token: verificationToken.token }
96127
} catch (error) {
97-
return { status: 'ERROR', reason: 'failed to create ephemeral session token' }
128+
return { status: 'ERROR', reason: 'failed to create verification token' }
98129
}
99130
}
100131

101-
async function redirectToDomain (res, domainName, token, redirectUri) {
132+
async function redirectToDomain (res, domainName, verificationToken, redirectUri) {
102133
try {
134+
// create the target URL
103135
const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
104136
const target = new URL(`${protocol}://${domainName}`)
105137

106-
target.searchParams.set('token', token)
107-
// if redirectUri is provided, add it to the URL
108-
if (redirectUri && redirectUri !== '/') {
109-
target.searchParams.set('redirectUri', redirectUri)
110-
}
138+
// add the verification token and the redirectUri to the URL
139+
target.searchParams.set('token', verificationToken)
140+
target.searchParams.set('redirectUri', redirectUri)
111141

142+
// redirect to the custom domain
112143
res.redirect(302, target.href)
113144
} catch (error) {
114-
console.error('[authSync::redirectToDomain] error', error)
115145
return { status: 'ERROR', reason: 'could not construct the URL' }
116146
}
117147
}
148+
149+
async function consumeVerificationToken (verificationToken) {
150+
try {
151+
// find the verification token
152+
const { identifier } = await models.verificationToken.findFirst({
153+
where: {
154+
token: verificationToken,
155+
expires: { gt: new Date() }
156+
}
157+
})
158+
// if we can't find the verification token, it's invalid or expired
159+
if (!identifier) {
160+
return { status: 'ERROR', reason: 'invalid verification token' }
161+
}
162+
163+
// delete the verification token, we don't need it anymore
164+
await models.verificationToken.delete({
165+
where: {
166+
token: verificationToken
167+
}
168+
})
169+
170+
// return the user id
171+
return { status: 'OK', userId: Number(identifier) }
172+
} catch (error) {
173+
return { status: 'ERROR', reason: 'cannot validate verification token' }
174+
}
175+
}
176+
177+
async function createEphemeralSessionToken (userId) {
178+
try {
179+
// create a short-lived JWT session token with the user id
180+
const sessionToken = await encodeJWT({
181+
token: { id: userId, sub: userId },
182+
secret: process.env.NEXTAUTH_SECRET,
183+
maxAge: SYNC_TOKEN_MAX_AGE
184+
})
185+
186+
// return the ephemeral session token
187+
return { status: 'OK', sessionToken }
188+
} catch (error) {
189+
return { status: 'ERROR', reason: 'failed to create ephemeral session token' }
190+
}
191+
}

0 commit comments

Comments
 (0)