initial commit

This commit is contained in:
Madison Grubb
2026-02-10 23:32:26 -05:00
commit b7046dc0e6
133 changed files with 26080 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import { getAuthConfig } from '../../utils/authConfig.js'
export default defineEventHandler(() => getAuthConfig())

View File

@@ -0,0 +1,34 @@
import { setCookie } from 'h3'
import { getDb } from '../../utils/db.js'
import { verifyPassword } from '../../utils/password.js'
import { getSessionMaxAgeDays } from '../../utils/session.js'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const identifier = body?.identifier?.trim()
const password = body?.password
if (!identifier || typeof password !== 'string') {
throw createError({ statusCode: 400, message: 'identifier and password required' })
}
const { get, run } = await getDb()
const user = await get('SELECT id, identifier, role, password_hash FROM users WHERE identifier = ?', [identifier])
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
throw createError({ statusCode: 401, message: 'Invalid credentials' })
}
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()
const expires = new Date(now.getTime() + sessionDays * 24 * 60 * 60 * 1000)
await run(
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
[sid, user.id, now.toISOString(), expires.toISOString()],
)
setCookie(event, 'session_id', sid, {
httpOnly: true,
sameSite: 'strict',
path: '/',
maxAge: sessionDays * 24 * 60 * 60,
secure: process.env.NODE_ENV === 'production',
})
return { user: { id: user.id, identifier: user.identifier, role: user.role } }
})

View File

@@ -0,0 +1,18 @@
import { deleteCookie, getCookie } from 'h3'
import { getDb } from '../../utils/db.js'
export default defineEventHandler(async (event) => {
const sid = getCookie(event, 'session_id')
if (sid) {
try {
const { run } = await getDb()
await run('DELETE FROM sessions WHERE id = ?', [sid])
}
catch {
// ignore
}
deleteCookie(event, 'session_id', { path: '/' })
}
setResponseStatus(event, 204)
return null
})

View File

@@ -0,0 +1,41 @@
import { getAuthConfig } from '../../../utils/authConfig.js'
import {
getOidcConfig,
getOidcRedirectUri,
createOidcParams,
getCodeChallenge,
buildAuthorizeUrl,
} from '../../../utils/oidc.js'
const SCOPES = process.env.OIDC_SCOPES || 'openid profile email'
export default defineEventHandler(async (event) => {
const { oidc: { enabled } } = getAuthConfig()
if (!enabled) throw createError({ statusCode: 400, message: 'OIDC not enabled' })
const config = await getOidcConfig()
if (!config) throw createError({ statusCode: 500, message: 'OIDC not configured' })
const redirectUri = getOidcRedirectUri()
const { state, nonce, codeVerifier } = createOidcParams()
const codeChallenge = await getCodeChallenge(codeVerifier)
const params = {
redirect_uri: redirectUri,
scope: SCOPES,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
}
const url = buildAuthorizeUrl(config, params)
setCookie(event, 'oidc_state', JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 600,
secure: process.env.NODE_ENV === 'production',
})
return sendRedirect(event, url.href, 302)
})

View File

@@ -0,0 +1,96 @@
import { getCookie, deleteCookie, setCookie, getRequestURL } from 'h3'
import {
getOidcConfig,
constantTimeCompare,
validateRedirectPath,
exchangeCode,
} from '../../../utils/oidc.js'
import { getDb } from '../../../utils/db.js'
import { getSessionMaxAgeDays } from '../../../utils/session.js'
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
function getIdentifier(claims) {
return claims?.email ?? claims?.preferred_username ?? claims?.name ?? claims?.sub ?? 'oidc-user'
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const code = query?.code
const state = query?.state
if (!code || !state) throw createError({ statusCode: 400, message: 'Invalid request' })
const cookieRaw = getCookie(event, 'oidc_state')
if (!cookieRaw) throw createError({ statusCode: 400, message: 'Invalid request' })
let stored
try {
stored = JSON.parse(cookieRaw)
}
catch {
throw createError({ statusCode: 400, message: 'Invalid request' })
}
if (!stored?.state || !constantTimeCompare(state, stored.state)) {
throw createError({ statusCode: 400, message: 'Invalid request' })
}
const config = await getOidcConfig()
if (!config) throw createError({ statusCode: 500, message: 'OIDC not configured' })
const currentUrl = getRequestURL(event)
const checks = {
expectedState: state,
expectedNonce: stored.nonce,
pkceCodeVerifier: stored.codeVerifier,
}
let tokens
try {
tokens = await exchangeCode(config, currentUrl, checks)
}
catch {
deleteCookie(event, 'oidc_state', { path: '/' })
throw createError({ statusCode: 401, message: 'Authentication failed' })
}
deleteCookie(event, 'oidc_state', { path: '/' })
const claims = tokens.claims?.()
if (!claims?.sub) throw createError({ statusCode: 401, message: 'Authentication failed' })
const issuer = process.env.OIDC_ISSUER ?? ''
const { get, run } = await getDb()
let user = await get(
'SELECT id, identifier, role FROM users WHERE oidc_issuer = ? AND oidc_sub = ?',
[issuer, claims.sub],
)
if (!user) {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const identifier = getIdentifier(claims)
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, identifier, null, DEFAULT_ROLE, now, 'oidc', issuer, claims.sub],
)
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
}
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()
const expires = new Date(now.getTime() + sessionDays * 24 * 60 * 60 * 1000)
await run(
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
[sid, user.id, now.toISOString(), expires.toISOString()],
)
setCookie(event, 'session_id', sid, {
httpOnly: true,
sameSite: 'strict',
path: '/',
maxAge: sessionDays * 24 * 60 * 60,
secure: process.env.NODE_ENV === 'production',
})
const redirectParam = query?.redirect
const path = validateRedirectPath(redirectParam)
return sendRedirect(event, path.startsWith('http') ? path : new URL(path, getRequestURL(event).origin).href, 302)
})