1
- // SYNC sketchbook
2
- // WIP
3
-
4
- import { getServerSession } from 'next-auth/next'
5
- import { getAuthOptions } from './[...nextauth]'
1
+ // Auth Sync API
6
2
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'
8
5
import { validateSchema , customDomainSchema } from '@/lib/validate'
9
6
10
7
const SN_MAIN_DOMAIN = new URL ( process . env . NEXT_PUBLIC_URL )
11
8
const SYNC_TOKEN_MAX_AGE = 60 // 1 minute
12
9
13
10
export default async function handler ( req , res ) {
14
11
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 } )
28
36
}
29
37
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 )
34
75
}
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 )
43
76
} catch ( error ) {
44
77
return res . status ( 500 ) . json ( { status : 'ERROR' , reason : 'auth sync broke its legs' } )
45
78
}
@@ -51,67 +84,108 @@ async function isDomainAllowed (domainName) {
51
84
// check if domain is conformative
52
85
await validateSchema ( customDomainSchema , { domainName } )
53
86
// 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 ( {
56
88
where : { domainName, status : 'ACTIVE' }
57
89
} )
58
- if ( ! domainInfo ) {
90
+
91
+ if ( ! domain ) {
59
92
return { status : 'ERROR' , reason : 'domain not allowed' }
60
93
}
61
- return { status : 'OK' , domain : domainInfo }
94
+
95
+ // domain is valid and ACTIVE
96
+ return { status : 'OK' }
62
97
} catch ( error ) {
63
98
return { status : 'ERROR' , reason : 'domain is not valid' }
64
99
}
65
100
}
66
101
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
73
104
const syncUrl = new URL ( '/api/auth/sync' , SN_MAIN_DOMAIN )
74
105
syncUrl . searchParams . set ( 'domain' , domainName )
75
106
syncUrl . searchParams . set ( 'redirectUri' , redirectUri )
76
107
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 )
80
114
}
81
115
82
- // creates an ephemeral session token for the user
83
- async function createEphemeralSessionToken ( session , domainName ) {
116
+ async function createVerificationToken ( token ) {
84
117
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
+ }
94
125
} )
95
- return sessionToken
126
+ return { status : 'OK' , token : verificationToken . token }
96
127
} catch ( error ) {
97
- return { status : 'ERROR' , reason : 'failed to create ephemeral session token' }
128
+ return { status : 'ERROR' , reason : 'failed to create verification token' }
98
129
}
99
130
}
100
131
101
- async function redirectToDomain ( res , domainName , token , redirectUri ) {
132
+ async function redirectToDomain ( res , domainName , verificationToken , redirectUri ) {
102
133
try {
134
+ // create the target URL
103
135
const protocol = process . env . NODE_ENV === 'development' ? 'http' : 'https'
104
136
const target = new URL ( `${ protocol } ://${ domainName } ` )
105
137
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 )
111
141
142
+ // redirect to the custom domain
112
143
res . redirect ( 302 , target . href )
113
144
} catch ( error ) {
114
- console . error ( '[authSync::redirectToDomain] error' , error )
115
145
return { status : 'ERROR' , reason : 'could not construct the URL' }
116
146
}
117
147
}
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