| 
									
										
										
										
											2025-06-24 16:17:00 -07:00
										 |  |  | import { PASSWORD_ENTRIES } from '../../../models/password_entry.ts'; | 
					
						
							|  |  |  | import { USER, USERS } from '../../../models/user.ts'; | 
					
						
							| 
									
										
										
										
											2025-07-24 12:09:24 -07:00
										 |  |  | import { encodeBase32 } from '@std/encoding'; | 
					
						
							|  |  |  | import lurid from '@andyburke/lurid'; | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | import { SESSION, SESSIONS } from '../../../models/session.ts'; | 
					
						
							| 
									
										
										
										
											2025-06-24 16:17:00 -07:00
										 |  |  | import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; | 
					
						
							| 
									
										
										
										
											2025-07-24 12:09:24 -07:00
										 |  |  | import { encodeBase64 } from '@std/encoding/base64'; | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | import parse_body from '../../../utils/bodyparser.ts'; | 
					
						
							| 
									
										
										
										
											2025-07-04 15:16:51 -07:00
										 |  |  | import { get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; | 
					
						
							| 
									
										
										
										
											2025-07-24 12:09:24 -07:00
										 |  |  | import * as bcrypt from '@da/bcrypt'; | 
					
						
							| 
									
										
										
										
											2025-07-04 14:51:49 -07:00
										 |  |  | import { verifyTotp } from '../../../utils/totp.ts'; | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-08 18:10:09 -07:00
										 |  |  | const DEFAULT_SESSION_TIME: number = 28 * (24 * (60 * (60 * 1_000))); // 28 days
 | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-04 15:16:51 -07:00
										 |  |  | export const PRECHECKS: PRECHECK_TABLE = {}; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | // POST /api/auth - Authenticate
 | 
					
						
							|  |  |  | export async function POST(req: Request, meta: Record<string, any>): Promise<Response> { | 
					
						
							|  |  |  | 	try { | 
					
						
							|  |  |  | 		const body = await parse_body(req); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		const username: string = body.username?.toLowerCase() ?? ''; | 
					
						
							|  |  |  | 		const password: string | undefined = body.password; | 
					
						
							|  |  |  | 		const password_hash: string | undefined = body.password_hash ?? (typeof password === 'string' | 
					
						
							|  |  |  | 			? encodeBase64( | 
					
						
							|  |  |  | 				await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password ?? '')) | 
					
						
							|  |  |  | 			) | 
					
						
							|  |  |  | 			: undefined); | 
					
						
							|  |  |  | 		const totp: string | undefined = body.totp; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 		if (!username.length) { | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 			return Response.json({ | 
					
						
							|  |  |  | 				error: { | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 					message: 'You must specify a username to log in.', | 
					
						
							|  |  |  | 					cause: 'username_required' | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 				} | 
					
						
							|  |  |  | 			}, { | 
					
						
							|  |  |  | 				status: 400 | 
					
						
							|  |  |  | 			}); | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		// password has should be a base64-encoded SHA-256 hash
 | 
					
						
							|  |  |  | 		if (typeof password_hash !== 'string') { | 
					
						
							|  |  |  | 			return Response.json({ | 
					
						
							|  |  |  | 				error: { | 
					
						
							|  |  |  | 					message: 'invalid password hash', | 
					
						
							|  |  |  | 					cause: 'invalid_password_hash' | 
					
						
							|  |  |  | 				} | 
					
						
							|  |  |  | 			}, { | 
					
						
							|  |  |  | 				status: 400 | 
					
						
							|  |  |  | 			}); | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		let user: USER | undefined = undefined; | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 		user = (await USERS.find({ | 
					
						
							|  |  |  | 			username | 
					
						
							| 
									
										
										
										
											2025-07-02 21:28:07 -07:00
										 |  |  | 		})).shift()?.load(); | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 		if (!user) { | 
					
						
							|  |  |  | 			return Response.json({ | 
					
						
							|  |  |  | 				error: { | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 					message: `Could not locate an account with username: ${username}`, | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 					cause: 'missing_account' | 
					
						
							|  |  |  | 				} | 
					
						
							|  |  |  | 			}, { | 
					
						
							|  |  |  | 				status: 400 | 
					
						
							|  |  |  | 			}); | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-24 16:17:00 -07:00
										 |  |  | 		const password_entry = await PASSWORD_ENTRIES.get(user.id); | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 		if (!password_entry) { | 
					
						
							|  |  |  | 			return Response.json({ | 
					
						
							|  |  |  | 				error: { | 
					
						
							|  |  |  | 					message: 'Missing password entry for this account, please contact support.', | 
					
						
							|  |  |  | 					cause: 'missing_password_entry' | 
					
						
							|  |  |  | 				} | 
					
						
							|  |  |  | 			}, { | 
					
						
							|  |  |  | 				status: 500 | 
					
						
							|  |  |  | 			}); | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-24 12:09:24 -07:00
										 |  |  | 		const verified = bcrypt.compareSync(password_hash, password_entry.hash); | 
					
						
							| 
									
										
										
										
											2025-07-04 14:51:49 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 		if (!verified) { | 
					
						
							|  |  |  | 			return Response.json({ | 
					
						
							|  |  |  | 				error: { | 
					
						
							|  |  |  | 					message: 'Incorrect password.', | 
					
						
							|  |  |  | 					cause: 'incorrect_password' | 
					
						
							|  |  |  | 				} | 
					
						
							|  |  |  | 			}, { | 
					
						
							|  |  |  | 				status: 400 | 
					
						
							|  |  |  | 			}); | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-24 16:17:00 -07:00
										 |  |  | 		const totp_entry = await TOTP_ENTRIES.get(user.id); | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 		if (totp_entry) { | 
					
						
							|  |  |  | 			if (typeof totp !== 'string' || !totp.length) { | 
					
						
							|  |  |  | 				return Response.json({ | 
					
						
							|  |  |  | 					two_factor: true, | 
					
						
							|  |  |  | 					totp: true | 
					
						
							|  |  |  | 				}, { | 
					
						
							|  |  |  | 					status: 202, | 
					
						
							|  |  |  | 					headers: { | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 						'Set-Cookie': `totp_user_id=${user.id}; Path=/; Max-Age=300` | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 					} | 
					
						
							|  |  |  | 				}); | 
					
						
							|  |  |  | 			} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 			const valid_totp: boolean = await verifyTotp(totp, totp_entry.secret); | 
					
						
							|  |  |  | 			if (!valid_totp) { | 
					
						
							|  |  |  | 				return Response.json({ | 
					
						
							|  |  |  | 					error: { | 
					
						
							|  |  |  | 						message: 'Incorrect TOTP.', | 
					
						
							|  |  |  | 						cause: 'incorrect_totp' | 
					
						
							|  |  |  | 					} | 
					
						
							|  |  |  | 				}, { | 
					
						
							|  |  |  | 					status: 400 | 
					
						
							|  |  |  | 				}); | 
					
						
							|  |  |  | 			} | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 		const session_result: SESSION_RESULT = await create_new_session({ | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 			user, | 
					
						
							|  |  |  | 			expires: body.session?.expires | 
					
						
							|  |  |  | 		}); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		// TODO: verify this redirect is relative?
 | 
					
						
							|  |  |  | 		const headers = session_result.headers; | 
					
						
							|  |  |  | 		let status = 201; | 
					
						
							|  |  |  | 		if (typeof meta?.query?.redirect === 'string') { | 
					
						
							|  |  |  | 			const url = new URL(req.url); | 
					
						
							|  |  |  | 			session_result.headers.append('location', `${url.origin}${meta.query.redirect}`); | 
					
						
							|  |  |  | 			status = 302; | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		return Response.json({ | 
					
						
							|  |  |  | 			user, | 
					
						
							|  |  |  | 			session: session_result.session | 
					
						
							|  |  |  | 		}, { | 
					
						
							|  |  |  | 			status, | 
					
						
							|  |  |  | 			headers | 
					
						
							|  |  |  | 		}); | 
					
						
							|  |  |  | 	} catch (error) { | 
					
						
							|  |  |  | 		return Response.json({ | 
					
						
							|  |  |  | 			error: { | 
					
						
							|  |  |  | 				message: (error as Error).message ?? 'Unknown Error!', | 
					
						
							|  |  |  | 				cause: (error as Error).cause ?? 'unknown' | 
					
						
							|  |  |  | 			} | 
					
						
							|  |  |  | 		}, { status: 400 }); | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export type SESSION_RESULT = { | 
					
						
							|  |  |  | 	session: SESSION; | 
					
						
							|  |  |  | 	headers: Headers; | 
					
						
							|  |  |  | }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export type SESSION_INFO = { | 
					
						
							|  |  |  | 	user: USER; | 
					
						
							|  |  |  | 	expires: string | undefined; | 
					
						
							|  |  |  | }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-04 15:16:51 -07:00
										 |  |  | // DELETE /api/auth - log out (delete session)
 | 
					
						
							|  |  |  | PRECHECKS.DELETE = [get_session, get_user, require_user]; | 
					
						
							|  |  |  | const back_then = new Date(0).toISOString(); | 
					
						
							|  |  |  | export async function DELETE(_request: Request, meta: Record<string, any>): Promise<Response> { | 
					
						
							|  |  |  | 	await SESSIONS.delete(meta.session); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	const headers = new Headers(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=; Path=/; Expires=${back_then}`); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	// TODO: this wasn't really intended to be persisted in a cookie, but we are using it to
 | 
					
						
							|  |  |  | 	// generate the TOTP for the call to /api/users/me
 | 
					
						
							|  |  |  | 	headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=; Path=/; Expires=${back_then}`); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	return Response.json({ | 
					
						
							|  |  |  | 		deleted: true | 
					
						
							|  |  |  | 	}, { | 
					
						
							|  |  |  | 		status: 200, | 
					
						
							|  |  |  | 		headers | 
					
						
							|  |  |  | 	}); | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-04 14:51:49 -07:00
										 |  |  | const session_secret_buffer = new Uint8Array(20); | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | export async function create_new_session(session_settings: SESSION_INFO): Promise<SESSION_RESULT> { | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 	const now = new Date().toISOString(); | 
					
						
							|  |  |  | 	const expires: string = session_settings.expires ?? | 
					
						
							|  |  |  | 		new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString(); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-04 14:51:49 -07:00
										 |  |  | 	crypto.getRandomValues(session_secret_buffer); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 	const session: SESSION = { | 
					
						
							|  |  |  | 		id: lurid(), | 
					
						
							|  |  |  | 		user_id: session_settings.user.id, | 
					
						
							| 
									
										
										
										
											2025-07-04 14:51:49 -07:00
										 |  |  | 		secret: encodeBase32(session_secret_buffer), | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 		timestamps: { | 
					
						
							|  |  |  | 			created: now, | 
					
						
							|  |  |  | 			expires, | 
					
						
							|  |  |  | 			ended: '' | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 	}; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	await SESSIONS.create(session); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	const headers = new Headers(); | 
					
						
							| 
									
										
										
										
											2025-06-25 20:51:29 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 	headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Expires=${expires}`); | 
					
						
							|  |  |  | 	headers.append(`x-${SESSION_ID_TOKEN}`, session.id); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	// TODO: this wasn't really intended to be persisted in a cookie, but we are using it to
 | 
					
						
							|  |  |  | 	// generate the TOTP for the call to /api/users/me
 | 
					
						
							|  |  |  | 	headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=${session.secret}; Path=/; Expires=${expires}`); | 
					
						
							| 
									
										
										
										
											2025-06-24 15:40:30 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 	return { | 
					
						
							|  |  |  | 		session, | 
					
						
							|  |  |  | 		headers | 
					
						
							|  |  |  | 	}; | 
					
						
							|  |  |  | } |