Skip to content

Commit 032d3e6

Browse files
committedFeb 28, 2025·
feat(auth): implement password hashing utility
Add Password class with hash and verify methods using SHA3-256 for secure password handling with salting via environment configuration.
1 parent 6c4d57f commit 032d3e6

File tree

4 files changed

+130
-146
lines changed

4 files changed

+130
-146
lines changed
 

‎.env.example

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
# The database URL is used to connect to your Neon database (pooler).
88
DATABASE_URL="postgres://[USERNAME]:[PASSWORD]@ep-lively-haze-a10zei6o-pooler.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"
99

10+
# The secret used to hash password
11+
# generate a new secret using `openssl rand -base64 32`
12+
AUTH_SECRET=
1013
# Preconfigured Discord OAuth provider, works out-of-the-box
1114
# @see https://arcticjs.dev/guides/oauth2
1215
DISCORD_CLIENT_ID=

‎packages/auth/src/env.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const env = createEnv({
66
extends: [vercel()],
77
server: {
88
NODE_ENV: z.enum(['development', 'production']).optional(),
9+
AUTH_SECRET: z.string().min(1),
910
DISCORD_CLIENT_ID: z.string().min(1),
1011
DISCORD_CLIENT_SECRET: z.string().min(1),
1112
GOOGLE_CLIENT_ID: z.string().min(1),

‎packages/auth/src/utils/auth.ts

+117-144
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { User } from '@prisma/client'
22
import type { OAuth2Tokens } from 'arctic'
3+
import type { NextRequest } from 'next/server'
34
import { cookies } from 'next/headers'
45
import { redirect } from 'next/navigation'
5-
import { generateCodeVerifier, generateState } from 'arctic'
6+
import { NextResponse } from 'next/server'
7+
import { generateCodeVerifier, generateState, OAuth2RequestError } from 'arctic'
68
import { z } from 'zod'
79

810
import { db } from '@yuki/db'
@@ -17,14 +19,12 @@ export interface AuthOptions {
1719
providers: Providers
1820
}
1921

20-
type SignInType = 'credentials' | 'discord' | 'google'
21-
2222
class AuthClass {
2323
private readonly db: typeof db
2424
private readonly session: Session
2525
private readonly password: Password
26-
private readonly COOKIE_KEY: string
2726

27+
private readonly COOKIE_KEY: string
2828
private readonly providers: Providers
2929

3030
constructor(options: AuthOptions) {
@@ -36,79 +36,87 @@ class AuthClass {
3636
this.password = new Password()
3737
}
3838

39-
public async auth(req?: Request): Promise<SessionResult> {
39+
public async auth(req?: NextRequest): Promise<SessionResult> {
4040
let authToken: string | undefined
4141

42-
if (req) {
43-
const cookies = this.parsedCookies(req.headers)
42+
if (req)
4443
authToken =
45-
cookies[this.COOKIE_KEY] ??
44+
req.cookies.get(this.COOKIE_KEY)?.value ??
4645
req.headers.get('Authorization')?.replace('Bearer ', '')
47-
} else {
48-
authToken = (await cookies()).get(this.COOKIE_KEY)?.value
49-
}
46+
else authToken = (await cookies()).get(this.COOKIE_KEY)?.value
5047

5148
if (!authToken) return { expires: new Date() }
5249

5350
return await this.session.validateSessionToken(authToken)
5451
}
5552

56-
public async handlers(req: Request): Promise<Response> {
57-
const url = new URL(req.url)
53+
public async handlers(req: NextRequest): Promise<Response> {
54+
const url = new URL(req.nextUrl)
5855

59-
let response: Response = new Response('Not found', { status: 404 })
56+
let response: NextResponse = NextResponse.json(
57+
{ error: 'Not found' },
58+
{ status: 404 },
59+
)
6060

6161
switch (req.method) {
6262
case 'OPTIONS':
63-
response = new Response('', { status: 204 })
63+
response = NextResponse.json('', { status: 204 })
6464
break
6565
case 'GET':
6666
if (url.pathname === '/api/auth') {
6767
const session = await this.auth(req)
68-
response = new Response(JSON.stringify(session))
68+
response = NextResponse.json(session)
6969
} else if (url.pathname.startsWith('/api/auth/oauth')) {
7070
const isCallback = url.pathname.endsWith('/callback')
7171

7272
if (!isCallback) {
73-
const provider = String(url.pathname.split('/').pop())
73+
const provider =
74+
this.providers[String(url.pathname.split('/').pop())]
75+
76+
if (!provider) {
77+
response = NextResponse.json(
78+
{ error: 'Provider not supported' },
79+
{ status: 404 },
80+
)
81+
break
82+
}
7483
const state = generateState()
7584
const codeVerifier = generateCodeVerifier()
76-
const authorizationUrl = this.providers[
77-
provider
78-
]?.createAuthorizationURL(state, codeVerifier)
79-
80-
if (!authorizationUrl) {
81-
response = new Response('Provider not found', { status: 404 })
82-
} else {
83-
response = new Response('', { status: 302 })
84-
response.headers.set('Location', authorizationUrl.toString())
85-
response.headers.set('Set-Cookie', `oauth_state=${state}; Path=/`)
86-
87-
this.setCookie(response, 'oauth_state', state, { maxAge: 600 })
88-
this.setCookie(response, 'code_verifier', codeVerifier, {
89-
maxAge: 600,
90-
})
91-
}
85+
const authorizationUrl = provider.createAuthorizationURL(
86+
state,
87+
codeVerifier,
88+
)
89+
90+
response = NextResponse.redirect(
91+
new URL(authorizationUrl, req.nextUrl),
92+
)
93+
response.cookies.set('code_verifier', codeVerifier)
94+
response.cookies.set('oauth_state', state)
9295
} else {
93-
const cookies = this.parsedCookies(req.headers)
96+
const provider =
97+
this.providers[String(url.pathname.split('/').slice(-2)[0])]
98+
if (!provider) {
99+
response = NextResponse.json(
100+
{ error: 'Provider not supported' },
101+
{ status: 404 },
102+
)
103+
break
104+
}
94105

95-
const provider = String(url.pathname.split('/').slice(-2)[0])
96106
const code = url.searchParams.get('code')
97107
const state = url.searchParams.get('state')
98-
const storedState = cookies.oauth_state
99-
const codeVerifier = cookies.code_verifier ?? ''
108+
const storedState = req.cookies.get('oauth_state')?.value ?? ''
109+
const codeVerifier = req.cookies.get('code_verifier')?.value ?? ''
100110

101111
try {
102112
if (!code || !state || state !== storedState)
103113
throw new Error('Invalid state')
104114

105115
const { validateAuthorizationCode, fetchUserUrl, mapUser } =
106-
this.providers[provider] ?? {}
107-
if (!validateAuthorizationCode || !fetchUserUrl || !mapUser)
108-
throw new Error('Provider not found')
116+
provider
109117

110118
const verifiedCode = await validateAuthorizationCode(
111-
code,
119+
'dsadasd',
112120
codeVerifier,
113121
)
114122

@@ -117,41 +125,38 @@ class AuthClass {
117125
const res = await fetch(fetchUserUrl, {
118126
headers: { Authorization: `Bearer ${token}` },
119127
})
120-
if (!res.ok)
121-
throw new Error(`Failed to fetch user data from ${provider}`)
128+
if (!res.ok) throw new Error('Failed to fetch user data')
122129

123130
const user = await this.createUser(
124131
mapUser((await res.json()) as never),
125132
)
126133
const session = await this.session.createSession(user.id)
127134

128-
response = new Response('', { status: 302 })
129-
130-
response.headers.set('Location', '/')
131-
this.setCookie(response, this.COOKIE_KEY, session.sessionToken, {
135+
response = NextResponse.redirect(new URL('/', req.nextUrl))
136+
response.cookies.set(this.COOKIE_KEY, session.sessionToken, {
137+
httpOnly: true,
138+
path: '/',
139+
secure: env.NODE_ENV === 'production',
140+
sameSite: 'lax' as const,
132141
expires: session.expires,
133-
replace: true,
134-
})
135-
136-
const pastDate = new Date(0)
137-
this.setCookie(response, 'oauth_state', '', { expires: pastDate })
138-
this.setCookie(response, 'code_verifier', '', {
139-
expires: pastDate,
140142
})
143+
response.cookies.delete('oauth_state')
144+
response.cookies.delete('code_verifier')
141145
} catch (error) {
142-
if (error instanceof Error)
143-
response = new Response(
144-
JSON.stringify({ error: error.message }),
145-
{
146-
status: 400,
147-
},
146+
if (error instanceof OAuth2RequestError) {
147+
response = NextResponse.json(
148+
{ error: error.message, description: error.description },
149+
{ status: 400 },
150+
)
151+
} else if (error instanceof Error)
152+
response = NextResponse.json(
153+
{ error: error.message },
154+
{ status: 400 },
148155
)
149156
else
150-
response = new Response(
151-
JSON.stringify({ error: 'An error occurred' }),
152-
{
153-
status: 500,
154-
},
157+
response = NextResponse.json(
158+
{ error: 'An unknown error occurred' },
159+
{ status: 400 },
155160
)
156161
}
157162
}
@@ -160,11 +165,8 @@ class AuthClass {
160165
case 'POST':
161166
if (url.pathname === '/api/auth/sign-out') {
162167
await this.signOut(req)
163-
response = new Response('', { status: 302 })
164-
response.headers.set('Location', '/')
165-
this.setCookie(response, this.COOKIE_KEY, '', {
166-
expires: new Date(0),
167-
})
168+
response = NextResponse.redirect(new URL('/', req.url))
169+
response.cookies.delete(this.COOKIE_KEY)
168170
}
169171
break
170172
}
@@ -178,7 +180,7 @@ class AuthClass {
178180
data?: z.infer<typeof credentialsSchema>,
179181
): Promise<
180182
| { success: false; fieldErrors: Record<string, string[]> }
181-
| { success: true; session: { sessionToken: string; expires: Date } }
183+
| { success: true; message: string }
182184
| undefined
183185
> {
184186
if (type === 'credentials') {
@@ -198,63 +200,34 @@ class AuthClass {
198200
const passwordMatch = this.password.verify(password, user.password)
199201
if (!passwordMatch) throw new Error('Invalid password')
200202

201-
return {
202-
success: true,
203-
session: await this.session.createSession(user.id),
204-
}
203+
const session = await this.session.createSession(user.id)
204+
;(await cookies()).set('auth_token', session.sessionToken, {
205+
httpOnly: true,
206+
path: '/',
207+
secure: env.NODE_ENV === 'production',
208+
sameSite: 'lax' as const,
209+
expires: session.expires,
210+
})
211+
212+
return { success: true, message: 'Signed in' }
205213
} else {
206214
redirect(`/api/auth/oauth/${type}`)
207215
}
208216
}
209217

210-
public async signOut(req?: Request): Promise<void> {
211-
let token: string
212-
213-
if (req) {
214-
const cookies = this.parsedCookies(req.headers)
215-
token =
216-
cookies[this.COOKIE_KEY] ??
217-
req.headers.get('Authorization')?.replace('Bearer ', '') ??
218-
''
219-
} else {
220-
token = (await cookies()).get(this.COOKIE_KEY)?.value ?? ''
221-
}
222-
218+
public async signOut(req?: NextRequest): Promise<void> {
219+
const token = await this.getToken(req)
223220
await this.session.invalidateSessionToken(token)
224-
if (!req) (await cookies()).delete(this.COOKIE_KEY)
225221
}
226222

227-
private parsedCookies(headers: Headers): Record<string, string> {
228-
const cookiesHeader = headers.get('cookie')
229-
if (!cookiesHeader) return {}
230-
231-
return cookiesHeader.split(';').reduce((acc, cookie) => {
232-
const [name, value] = cookie.split('=') as [string, string]
233-
return { ...acc, [name.trim()]: value }
234-
}, {})
235-
}
236-
237-
private setCookie(
238-
response: Response,
239-
name: string,
240-
value: string,
241-
options: {
242-
expires?: Date
243-
maxAge?: number
244-
replace?: boolean
245-
} = {},
246-
): void {
247-
const { expires, maxAge, replace = false } = options
248-
249-
let cookieValue = `${name}=${value}; HttpOnly; Path=/; SameSite=Lax`
250-
251-
if (expires) cookieValue += `; Expires=${expires.toUTCString()}`
252-
else if (maxAge !== undefined) cookieValue += `; Max-Age=${maxAge}`
253-
254-
if (env.NODE_ENV === 'production') cookieValue += '; Secure'
255-
256-
if (replace) response.headers.set('Set-Cookie', cookieValue)
257-
else response.headers.append('Set-Cookie', cookieValue)
223+
private async getToken(req?: NextRequest): Promise<string> {
224+
if (req)
225+
return (
226+
req.cookies.get(this.COOKIE_KEY)?.value ??
227+
req.headers.get('Authorization')?.replace('Bearer ', '') ??
228+
''
229+
)
230+
return (await cookies()).get(this.COOKIE_KEY)?.value ?? ''
258231
}
259232

260233
private async createUser(data: {
@@ -298,14 +271,37 @@ class AuthClass {
298271
}
299272

300273
private setCorsHeaders(res: Response): void {
301-
res.headers.set('Content-Type', 'application/json')
302274
res.headers.set('Access-Control-Allow-Origin', '*')
303275
res.headers.set('Access-Control-Request-Method', '*')
304276
res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST')
305277
res.headers.set('Access-Control-Allow-Headers', '*')
306278
}
307279
}
308280

281+
export const Auth = (options: AuthOptions) => {
282+
const authInstance = new AuthClass(options)
283+
284+
return {
285+
auth: (req?: NextRequest) => authInstance.auth(req),
286+
signIn: (type: SignInType, data?: z.infer<typeof credentialsSchema>) =>
287+
authInstance.signIn(type, data),
288+
signOut: (req?: NextRequest) => authInstance.signOut(req),
289+
handlers: (req: NextRequest) => authInstance.handlers(req),
290+
}
291+
}
292+
293+
const credentialsSchema = z.object({
294+
email: z.string().email(),
295+
password: z
296+
.string()
297+
.min(8, 'Password must be at least 8 characters')
298+
.regex(
299+
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
300+
'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character',
301+
),
302+
})
303+
304+
type SignInType = 'credentials' | 'discord' | 'google'
309305
type Providers = Record<
310306
string,
311307
{
@@ -324,26 +320,3 @@ type Providers = Record<
324320
}
325321
}
326322
>
327-
328-
const credentialsSchema = z.object({
329-
email: z.string().email(),
330-
password: z
331-
.string()
332-
.min(8, 'Password must be at least 8 characters')
333-
.regex(
334-
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
335-
'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character',
336-
),
337-
})
338-
339-
export const Auth = (options: AuthOptions) => {
340-
const authInstance = new AuthClass(options)
341-
342-
return {
343-
auth: (req?: Request) => authInstance.auth(req),
344-
signIn: (type: SignInType, data?: z.infer<typeof credentialsSchema>) =>
345-
authInstance.signIn(type, data),
346-
signOut: (req?: Request) => authInstance.signOut(req),
347-
handlers: (req: Request) => authInstance.handlers(req),
348-
}
349-
}

‎packages/auth/src/utils/password.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { sha3_256 } from '@oslojs/crypto/sha3'
22
import { encodeBase32LowerCase } from '@oslojs/encoding'
33

4+
import { env } from '../env'
5+
46
export class Password {
57
public hash(password: string): string {
6-
return encodeBase32LowerCase(sha3_256(new TextEncoder().encode(password)))
8+
const saltedPassword = `${password}${env.AUTH_SECRET}`
9+
return encodeBase32LowerCase(
10+
sha3_256(new TextEncoder().encode(saltedPassword)),
11+
)
712
}
813

914
public verify(password: string, hash: string): boolean {
15+
const saltedPassword = `${password}${env.AUTH_SECRET}`
16+
1017
const hashPassword = encodeBase32LowerCase(
11-
sha3_256(new TextEncoder().encode(password)),
18+
sha3_256(new TextEncoder().encode(saltedPassword)),
1219
)
1320
return hashPassword === hash
1421
}

0 commit comments

Comments
 (0)
Please sign in to comment.